We’ve had several submissions in the past which failed to fully utilise rails’ collection proxies. For future reference, I figured I’d write up the most commonly missed opportunities. To demonstrate this we’ll assume a Todo list application where each user can manage multiple Todo lists, with a simple model like this:
1 2 3 4 5 6 7
class User < ActiveRecord::Base has_many :todo_lists end class TodoList < ActiveRecord::Base belongs_to :user endCase One – Restricting Access to User Data
If you’re following the new restful conventions the url to access a Todo List is going to look something like http://todo.example.com/todo_lists/1
To keep your application secure, you’ll want to ensure that users can’t just change the ID in the url and manage someone else’s tasks. This is one of the most commonly asked questions on mailing lists and IRC channels, and we’ve seen quite a few anti-patterns to solve it. Some of the most common anti-patterns we’ve seen are these:
Anti-Pattern #1: Manually specifying the IDs when you construct the queries;
1 2 3 4 5
def show unless @todo_list = TodoList.find_by_id_and_user_id(params[:id], current_user.id) redirect_to '/' end end
Anti-Pattern #2: Querying globally, then checking ownership after the fact;
1 2 3 4
def show @todo_list = TodoList.find(params[:id]) redirect_to '/' unless @todo_list.user_id = current_user.id end
Anti-Pattern #3: Abusing with_scope for a this simple case either directly, or in an around_filter.
1 2 3 4 5
def show
with_scope(:find=>{:user_id=>current_user.id}) do
@todo_list = TodoList.find(params[:id])
end
end
Best Practice: The most effective way to do this is to call find on the todo_lists association.
1 2 3
def show @todo_list = current_user.todo_lists.find(params[:id]) end
In the event that the user plays with the URL to point to something they don’t own, an ActiveRecord::RecordNotFound exception will be raised. In general I simply ignore these exceptions as it’s unlikely they’re caused by legitimate use, but if you want to handle them you can simply rescue the exception.
1 2 3 4 5 6
def show @todo_list = current_user.todo_lists.find(params[:id]) rescue ActiveRecord::RecordNotFound => e flash[:warning] = "Stop playing around with your urls" redirect_to '/' end
Additionally, the find method on todo_lists is just like the regular find method; you’re not restricted to merely passing it an ID. So to build a secure search action you can do something like:
1 2 3 4
def search
@todo_lists = current_user.todo_lists.find(:all, :conditions=>["name like ?", params[:q]],
:include=>[:items])
end
That dispatching isn’t limited to find, you can call any method defined on the TodoList class. For example, say you wanted to encapsulate the search behaviour inside the TodoList class, you can define a class method:
1 2 3 4 5 6
class TodoList < ActiveRecord::Base
# ...
def self.search(query)
find(:all, :conditions=>["name like ?", query], :include=>[:items])
end
end
Then you can call that by using class methods on the has_many association:
1 2 3
def search @todo_lists = current_user.todo_lists.search(params[:q]) endCase Two – Assigning Ownership
Just as you need to find TodoLists for a user, your Todo list application will also need to create new lists for a user. Frequently in submissions we’ll see something like this:
1 2 3 4 5 6
def create @todo_list = TodoList.new(params[:todo_list]) @todo_list.user_id = current_user.id @todo_list.save! redirect_to todo_list_url(@todo_list) end
Just like find, the has_many association creates a number of methods to help you create new members of the todo_lists collection. If there’s no validation on the todo list model, then the simplest option is just to use create!:
1 2 3 4
def create @todo_list = current_user.todo_lists.create! params[:todo_list] redirect_to todo_list_url(@todo_list) end
This will raise an exception in the event the model fails validation, which prevents your application from silently failing. However, it doesn’t provide a particularly good user experience (in fact, it sucks). In situations where validation failures are likely, you can still use the association proxies:
1 2 3 4 5 6 7 8
def create
@todo_list = current_user.todo_lists.build params[:todo_list]
if @todo_list.save
redirect_to todo_list_url(@todo_list)
else
render :action=>'new'
end
end
Case Three – Special Queries
The final case I wanted to cover is where association declarations can be used to save you time is where you have a special query you’re frequently using. In our case, let’s say you occasionally want to get the user’s completed lists, and their active ones. The simplest way to get these is:
1 2 3 4
def show @completed_lists = current_user.todo_lists.find_all_by_complete(false) @active_lists = current_user.todo_lists.find_all_by_complete(true) end
However if you’ve been following our posts to date, you probably know that you should make your model match your domain, and that means your statements should read reasonably close to plain English. So if you want to find the user’s active_lists, you should probably do that with something like current_user.active_lists. A first pass at that may be.
1 2 3 4 5 6 7 8 9
class User < ActiveRecord::Base
def completed_lists
todo_lists.find_all_by_completed(true)
end
def active_lists
todo_lists.find_all_by_completed(false)
end
end
However a big downside to this approach is that repeated calls to active_lists will issue the query again, then construct the records from the result set, wasting time and memory. The obvious solution is to make sure the calls are cached, and the easiest way to do that is to use a has_many association.
1 2 3 4
class User < ActiveRecord::Base
has_many :completed_lists, :class_name=>"TodoList", :conditions=>{:completed=>true }
has_many :active_lists, :class_name=>"TodoList", :conditions=>{:completed=>false}
end
No comments yet.
You must be logged in to add your own comment.