zargony.com

#![desc = "Random thoughts of a software engineer"]

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