17

What's the best way to construct a where clause using Rails ActiveRecord? For instance, let's say I have a controller action that returns a list of blog posts:

def index
  @posts = Post.all
end

Now, let's say I want to be able to pass in a url parameter so that this controller action only returns posts by a specific author:

def index
  author_id = params[:author_id]

  if author_id.nil?
    @posts = Post.all
  else
    @posts = Post.where("author = ?", author_id)
  end
end

This doesn't feel very DRY to me. If I were to add ordering or pagination or worse yet, more optional URL query string params to filter by, this controller action would get very complicated.

Kevin Pang
  • 41,172
  • 38
  • 121
  • 173

5 Answers5

23

How about:

def index
  author_id = params[:author_id]

  @posts = Post.scoped

  @post = @post.where(:author_id => author_id) if author_id.present?

  @post = @post.where(:some_other_condition => some_other_value) if some_other_value.present?
end

Post.scoped is essentially a lazy loaded equivalent to Post.all (since Post.all returns an array immediately, while Post.scoped just returns a relation object). This query won't be executed until you actually try to iterate over it in the view (by calling .each).

John Gibb
  • 10,603
  • 2
  • 37
  • 48
  • 2
    Scoped was deprecated. [This seems relevant](http://stackoverflow.com/a/18199294/673826) – mlt Oct 17 '14 at 17:41
3

Mmmh, the best approach you want to use can be to spread this in 2 actions

def index
   @post = Post.all
end

def get
  @post = Post.where("author=?", params[:author_id])
end

IMHO it has more sense if you think about a RESTful API, index means to list all and get (or show) to fetch the requested one and show it!

Paolo Perego
  • 393
  • 3
  • 9
  • That doesn't scale. What if now I want to show all posts by a specific author with the ability to further narrow it down by post date and/or category? Also, I think the "get" action is more commonly used to finding a resource by id. I'd expect "posts/1" to return a Post with id == 1, not author_id == 1. – Kevin Pang Apr 28 '11 at 14:39
  • exactly, the expected url would be /authors/1/posts. Think of authors as resources. Read about nested resources in this guide: http://guides.rubyonrails.org/routing.html (scroll to 2.7 - Nested Resources). – Patrick Lafleur Apr 28 '11 at 14:48
1

This question is pretty old but it still comes up high in google in 2019, and also some earlier answers have been deprecated, so I thought I would share a possible solution.

In the model introduce some scopes with a test for the existence of the parameter passed:

class Post
    scope :where_author_ids, ->(ids){ where(author_id: ids.split(‘,’)) if ids }
    scope :where_topic_ids,  ->(ids){ where(topic_id:  ids.split(‘,’)) if ids }

Then in the controller you can just put as many filters in as you wish e.g:

def list
    @posts = Post.where_author_ids(params[:author_ids])
                 .where_topic_ids(params[:topic_ids])                                  
                 .where_other_condition_ids(params[:other_condition_ids])
                 .order(:created_at)

The parameter can then be a single value or a comma separated list of values, both work fine.

If a param doesn’t exist it simply skips that where clause and doesn’t filter for that particular criteria. If the param exists but its value is an empty string then it will ‘filter out’ everything.

This solution won’t suit every circumstance of course. If you have a view page with several filters on, but upon first opening you want to show all your data instead of no data until you press a ‘submit’ button or similar (as this controller would) then you will have to tweak it slightly.

I’ve had a go at SQL injecting this and rails seems to do a good job of keeping everything secure as far as I can see.

R Jones
  • 71
  • 2
  • 3
0

Would something like this work?

def get
  raise "Bad parameters...why are you doing this?" unless params[:filter].is_a?(Hash)
  @post = Post.where(params[:filter])
end

Then you can do something like: ?filter[author_id]=1&filter[post_date]=... etc.

Rob Di Marco
  • 43,054
  • 9
  • 66
  • 56
  • Wouldn't that be a SQL injection vulnerability? – Kevin Pang Apr 28 '11 at 14:48
  • How so? The keys are filtered when creating the where conditions, and you are using variables for insertion, not unlike "author_id = ?". Check out http://api.rubyonrails.org/classes/ActiveRecord/Base.html – Rob Di Marco Apr 28 '11 at 14:53
  • Because you're taking the where clause as is from the query string. According to the ActiveRecord documentation's "Conditions" section: "The string form can be used for statements that don’t involve tainted data". That means if you construct the where clause from a string rather than an array or hash, you won't have your input sanitized. With your solution, a malicious user could craft a URL like "posts?filter[author_id]=1;drop table posts;" and it would drop my "posts" table, right? – Kevin Pang Apr 28 '11 at 14:58
  • That is the string form, we are using the hash form, which is protected like the array form in the above example. "The array form is to be used when the condition input is tainted and requires sanitization. The string form can be used for statements that don’t involve tainted data. The hash form works much like the array form, except only equality and range is possible". Se should double check to make sure that params[:filter] is a hash. Edited my answer to show this. – Rob Di Marco Apr 28 '11 at 15:07
  • I think Kevin is right that if the user put 'filter=;drop table users' in the query string, params[:filter] would be a string instead of a hash. If you verified that params[:filter] was a hash first, you'd probably be safe. – John Gibb Apr 28 '11 at 15:12
  • 1
    This is an interesting approach, but I'd still be concerned that arbitrary (if safe) where clauses are being passed to the database. A user could limit on a field with no index causing excessive database load, and guess your table schema by seeing which parameters cause an error. I suppose a whitelist could be implemented by doing `params[:filter].slice('author_id', 'post_date')`. – John Gibb Apr 28 '11 at 15:27
0

You should model url using nested resources. The expected url would be /authors/1/posts. Think of authors as resources. Read about nested resources in this guide: http://guides.rubyonrails.org/routing.html (scroll to 2.7 - Nested Resources).

Patrick Lafleur
  • 326
  • 2
  • 6
  • What if I want to optionally filter down by author, create date, and category? As the number of conditions grows, I'm going to very quickly hit a point where I need to conditionally append to my where clause. I shouldn't need to create controller actions to handle every combination of optional conditions. There must be a better, more scalable approach than this. – Kevin Pang Apr 28 '11 at 14:53
  • Yup, at a certain point you will filter using simple parameters. The url allows to identify the requested object but it can be filtered with params. Don't push too far the nesting! Finding the right balance is not always an easy task. – Patrick Lafleur Apr 28 '11 at 14:54
  • You're basically saying at some point I'm going to end back at the same problem I have. At which point my question still stands. How do you dynamically construct the query based on the presence of certain URL params? – Kevin Pang Apr 28 '11 at 14:59