Sharing controllers and views with polymorphic resources
A while ago I faced a problem with controllers for nested resources I wanted to reuse in different contexts (polymorphic resources). For example, I had a simple Todo
model that is used to store tasks that have to be done. Having multiple users which are organized in teams, the goal was to have user-specific (personal) todos and team-specific todos. Obviously, both kind of todos could be handled exactly the same way (same controller, same views) -- except that the context differs.
So here's a way to use a single controller for a polymorphic resource.
Model
The model setup is quite straightforward. Users belong to a team and teams have multiple users. Todos can be referenced by both using a polymorphic association:
class User < ActiveRecord::Base
belongs_to :team
has_many :todos, :as => :context
end
class Team < ActiveRecord::Base
has_many :users
has_many :todos, :as => :context
end
class Todo < ActiveRecord::Base
belongs_to :context, :polymorphic => true
end
Routing
Using nested resources in routes.rb, todos can be used in different contexts:
map.resource :personal do |personal|
personal.resources :todos # /personal/todos/:id => TodosController
end
map.resources :teams do |teams|
teams.resources :todos # /teams/:team_id/todos/:id => TodosController
end
Controller
Both cases (personal todos and team todos) are now routed to the same TodosController
. To distinguish between different contexts, the controller-method context
returns the current context, either the user for personal todos or the team for team todos:
helper_method :context
def context
if params[:team_id]
Team.find(params[:team_id])
else
current_user
end
end
This method can be either put to TodosController
or to ApplicationController
, which makes it available globally, so that you can use it with other nested resources (like photos or documents additionally to todos). I also made the context
method available as a helper in views, in case a view needs to find out about the current context.
The TodosController
can now use the context
method everywhere to access the todos, e.g. the index
method lists all todos:
def index
@todos = context.todos.find(:all)
end
Views
Using url helpers in views is a bit tricky. Rails provides url helpers for every resource. Helpers for nested resources get a name prefix of the parent resource:
personal_todo_url(1) # => .../personal/todos/1
team_todo_url(5, 1) # => .../teams/5/todos/1
So you need to use different url helpers with different parameters depending on the current context. If the current context is a team, team_todo_url
needs to be called using the team id and todo id. If the current context is the user, personal_todo_url
needs to be called and only a todo id must be provided (since personal is a singleton resource, it doesn't need an id).
If you'd take care of the above in every view, it'd probably result in a ton of awful looking conditions -- so better define a helper that automatically chooses the right url helper based on the current context. Since this helper is able to return a url for any todo object, let's call it todo_url
(just like the helper that rails would provide if the todo resource wouldn't be nested):
def todo_url (*args)
if Team === current_context
team_todo_url(current_context.id, *args)
else
personal_todo_url(*args)
end
end
Naming this helper todo_url
has another advantage: if Rails is asked to generate a url based on a model object, it uses modelname_url to build the url. Without todo_url
, calls like link_to('a todo', @todo)
or form_for(@todo)
won't work anymore. Defining todo_url
let these methods work again magically. You can create views like with a non-nested resource and it just works because the above helper method automatically chooses the right url based on the context.
Of course, todo_url
isn't the only url helper that's related to todos. Rails by default provides a bunch of url helpers per resource that you may or may not use (todosurl, todopath, newtodourl, formattedtodourl, and so on). If you only use few of them, you might be fine to manually define them like above. If you need many different url helpers or you're a perfectionist and want to have all of them, here's a slightly ugly piece of metaprogramming that I added to my url_helper.rb and that defines all of these helpers:
['todos'].each do |resource|
[['', resource.pluralize], ['', resource.singularize], ['new_', resource.singularize], ['edit_', resource.singularize]].each do |prefix, helper|
['', 'formatted_'].each do |formatted|
['_url', '_path'].each do |suffix|
define_method "#{formatted}#{prefix}#{helper}#{suffix}" do |*args|
if Team === context
send("#{formatted}#{prefix}team_#{helper}#{suffix}", context.id, *args)
elsif User === context
send("#{formatted}#{prefix}personal_#{helper}#{suffix}", *args)
else
raise NameError, "Unknown context"
end
end
end
end
end
end