Occasional encounters with Ruby on Rails.

Friday, February 02, 2007

Nested Polymorphic Active Resources



As has been mentioned elsewhere, it'd be awfully nice for Rails' ActiveResources to deal with Polymorphic nested relationships. Huh? Say you wanted to do something like acts_as_commentable, which allows you to add comments to any object through polymorphism. How do you make that RESTful, so that you could have a nice URL like blog/123/comment/127 and another like books/987/comment/29?

To start, you've obviously got to nest the resources. For the examples urls above you would add the following to your routes.rb:


map.resources :blogs do |blog|
blog.resources :comments, :name_prefix => 'blog_'
end
map.resources :books do |book|
book.resources :comments, :name_prefix => 'book_'
end


The :name_prefix option serves two purposes. First, it makes sure that the routes don't collide. Without it the second mapping would override the first and you'd never get to the blog comments. Second, it makes it possible to have clearly named routes. If you want to generate the url for the book comments you'll use book_comments_path().

Okay, that makes creating the routes easy enough, but what about processing them from the controller. How does the Comments controller, for example, know whether to find a Book object or a Blog object when it wants to list comments? And how does it figure out which Book or Blog object to instantiate? There was a good conversation about this on RailsWeenie. The solution -- determining the 'parent' object from the mappings -- was good but but could be run through the DRYer because, chances are, if you're using polymorphism then you're using it in more than one place.

To that end I created a module so that I did not need to recreate the code every time I wanted to get polymorphic. It's not much, but it's effective.


module PolymorphicResource
def self.included(base)
base.class_eval do
private
def find_polymorphic_resource
poly_type, poly_id = find_polymorphic_type_and_id
eval("%s.find(poly_id)" % poly_type)
end

def find_polymorphic_type_and_id
# just finds instances of /controller/id
sections = request.env['REQUEST_URI'].scan(%r{/(\w+)/(\d*)})

sections.map! do |controller_name, id|
[controller_name.singularize.camelize, id]
end

sections.pop # support one-level of polymorphism
end
end
end
end


With that in place, we've got to do a little work in the controller. First, we've got to extend the controller by adding the module so we add 'require PolymorphicResource' to the top of the controller class definition. Now my controller can now find the 'parent' with a simple call to find_polymorphic_resource. For example, the comments_controller#index method could be modified like this:

def index
@commentable = find_polymorphic_resource
@comments = @commentable.comments.find(:all)
...
end


And the comments_controller#create would be

def create
@commentable = find_polymorphic_resource
@comment = @commentable.comments.build(params[:comment])
...
end


The RailsWeenie conversation mentioned above makes a good point that the only places in which you need to add the call to find_polymorphic_resource are in index and create. Everywhere else you can find the polymorphic entity through a reference to the referenced object. For example, the comments_controller#destroy method would now look like this:

def destroy
@comment = Comment.find(params[:id])
@comment.destroy

respond_to do |format|
format.html { redirect_to @comment.commentable.class.is_a?(Book) ? book_comments_url(@comment.commentable) : blog_comments_url(@comment.commentable)}
format.xml { head :ok }
end
end

No comments: