Tuesday, May 22, 2007

DRYing Up Polymorphic Controllers

Polymorphic routes allow drying up the controller implementation when functionality is identical, regardless of entry point. A good example is comments for articles and blogs. There is a challenge to balance the implementation of the comments controller reflecting the multiple incoming routes. Let's look at the way it could be written.

Routing is straightforward with blogs and article models acting as commentable and both the comment model and comment controllers being polymorphic:

ActionController::Routing::Routes.draw do |map|
map.resources :articles, :has_many => [ :comments ]
map.resources :blogs, :has_many => [ :comments ]
end


This means that a comment can be created via post to either /articles/1/comments/new or /blogs/1/comments/new. The comments controller can be implemented to handle both:

class CommentsController < ApplicationController

def new
@parent = parent_object
@comment = Comment.new
end

def create

@parent = parent_object
@comment = @parent.comments.build(params[:comment])

if @comment.valid? and @comment.save
redirect_to parent_url(@parent)
else
render :action => 'new'
end

end

private

def parent_object
case
when params[:article_id] then Article.find_by_id(params[:article_id])
when params[:news_id] then News.find_by_id(params[:news_id])
end
end

def parent_url(parent)
case
when params[:article_id] then article_url(parent)
when params[:news_id] then news_url(parent)
end
end

end


This method works fine and there is not much drive to start refactoring it right away. This changes, though, if there is a need to add another commentable or allow some other polymorphic route. Instead of adding more 'when' clauses the whole functionality can be extracted and abstracted based on the idea of having fixed naming conventions for resources that allow movement from a controller name to a model. The refactored example has the parent functionality extracted to the application controller to share it as-is with other polymorphic routes:

class ApplicationController < ActionController::Base

protected

class << self

attr_reader :parents

def parent_resources(*parents)
@parents = parents
end

end

def parent_id(parent)
request.path_parameters["#{ parent }_id"]
end

def parent_type
self.class.parents.detect { |parent| parent_id(parent) }
end

def parent_class
parent_type && parent_type.to_s.classify.constantize
end

def parent_object
parent_class && parent_class.find_by_id(parent_id(parent_type))
end

end

class CommentsController < ApplicationController

parent_resources :article, :blogs

def new
@parent = parent_object
@comment = Comment.new
end

def create

@parent = parent_object
@comment = @parent.comments.build(params[:comment])

if @comment.valid? and @comment.save
redirect_to send("#{ parent_type }_url", @parent)
else
render :action => 'new'
end

end

end

The parent_resources call declares resources that are parent for a current controller. An alternative approach is to guess such parent resources from the request URI and routes. Aaron is currently working on a patch on Edge implementing it. We'll update this post later.

If you currently use multiple polymorphic resources and have if clauses in the controller code, you might want to rethink how it could be DRYed up using this approach. In some cases views are very parent type specific. Then it might be better to have different templates and partials rendered via render :template => "/controller/#{ parent_type }_action".

16 comments:

Dong Bin said...

Very nice, looking for it for a long time.

Anonymous said...

Thanks a lot. That's exactly what I was looking for.

Good job.

badcarl said...

you should now be able to do:

redirect_to url_for(@parent)

instead of

redirect_to send("#{ parent_type }_url", @parent)

(in edge)

carlivar said...

There's another way that I picked up off the Rails mailing list a while ago.

It involves simply applying a regex to the URL in order to grab object class and id. I suppose that's kind of "cheating" but it's simple and gets the job done without a bunch of 'when' clauses.

I'd paste the code but this Google blog comment interface is terrible.

jnunemaker said...

Really slick. Thanks for the tip.

Nima Negahban said...

guys this is very nice, but the route I took (no pun intended) was to standardize on variable name for node - parent node and then actually generate the routes in the model itself this seems more elegant and I guess will be the standard but it was easier to conceptualize & it's slightly more flexible by having them generate them in the model.

jmcopeland said...

@nima: could you post an example of how you generated the routes in the model?

Using the above mentioned technique I am now running into problems with my view when calling paths because of nested routes needed to have the parent_type prepended to every path.

For example- map.resources client, :has_many => :addresses
map.resources contact, :has_many => :addresses

now the view requires client_address_path or contact_address_path (instead of just address_path)
but I can't access parent_type from the view for use on a send("#{parent_type}"_address_path)

anyone have a suggestion on a way around this? Am I overlooking a clever use of url_for ?

Val Aleksenko said...

jmcopeland: if you on edge, you can use url_for([@parent, @address]). Otherwise, just pass parent_type and parent_obj via @ from the controller to a view so you can use send("#{ parent_type }_address_path", @parent_obj, @address). Hope it helps.

jmcopeland said...

@val: thanks for help, I wasn't aware url_for took an array. Is there a way to use url_for with nested routes and an edit or create action? perhaps tacking on a :method => post to the url or something? If possible I would prefer to avoid all the send("#{ @parent_type }_new_address_path", @parent_obj) calls in the view.

In the view I need the client_new_address_path and client_edit_address_path.

I guess what I really need is a way to generate all new routes with parent_address_path instead of the client_address_path or contact_address_path... hmmm... not exactly sure what i want to do with this... these nested polymorphic routes are a pain.

Nima Negahban said...

According to Ryan D's blog url_for is strictly for internal Rails usage only. As for my technique its very simple just standardize your parent and child instance variable names and they're method names.

Then generate the Restful routes just like RAILS expects them I.E. edit = /some_url;edit?_method=get. Sure its not pretty but it sure flows nice when you are programming

jmcopeland said...

This comment has been removed because it linked to malicious content. Learn more.

Henrik Nyh said...

Nice. I'm currently working with polymorphic controllers and trying to find ways to tidy things up. This helped.

Your class instance variable doesn't inherit, though, so I did this instead:

class_inheritable_accessor :parents
def self.parent_resources(*parents)
self.parents = parents
end

Henrik Nyh said...

Oh, and I did

def parent
return @parent if @parent
@parent = parent_class && parent_class.find_by_id(parent_id(parent_type))
instance_variable_set("@#{parent_type}", @parent) # Layouts usually want a @user, @item etc
end

to keep down on DB hits (or is Rails smart enough these days to do that anyway?) and for the reason mentioned in the comment.

Matte E said...

Have you looked at the ResourceFu plugin? I'm using it for polymorphic attendees to both webinars and training courses. I haven't found any documentation outside the readme but it works really well once you understand it.

Alexander said...

Take a look at http://dev.rubyonrails.org/ticket/9932

map.resources :posts, :polymorphic => [:events, :articles] do |posts|
posts.resources :assets, :polymorphic => [:images, :movies]
end

edit_post_asset_path(:post_type => 'events', :post_id =>
1, :type => 'movies', :id => 2)

=> "/events/1/movies/2/edit"

Useful, isn't it?

cyberf said...

My users can have one (has_one) blog and I want to do something like this:
/users/1/blog/posts
But this doesn't work with this code 'cause I can't obtain the blog ID using parent_id from the Posts controller.
Any clue on how to extend this to obtain the blog ID?