Nested CRUD resources in Rails 1.2

Jan 10 by Andre
The CRUD methodology in Rails 1.2 is a great way to simplify your application structure. One of the first things you will find yourself doing is nesting one resource inside another. This is just the RESTful way of working with has_many relationships. The first couple times I did this, I forgot some of the steps (particularly changing the url paths in the controllers). So, I'm going to iterate the steps here -- hopefully this will help someone going through the learning process on Rails 1.2 CRUD.

1. Create the resource scaffolding and migrations

Don't worry, scaffold_resource doesn't have the same stigma that old-school scaffolds had back in the day. Scaffold_resource is the easiest way to jump-start a RESTful controller, and the actions/views it creates are both legitimate and useful. Let's step through the process with post has_many :comments

ruby script/generate scaffold_resource Post title:string body:text
ruby script/generate scaffold_resource Comment body:text

FYI, I like to create my migrations first, and just use scaffold_resource to generate whatever subset of columns I'd like to get a jumpstart with in the UI. Your preference may vary.
Before running your migrations, go into the comments migration and add the foreign key to posts: t.column :post_id, :integer.

2. Set up relationships in models.

Nothing unusual here:

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post
end

3. Modify routes.rb

The scoffold_resource generator calls will have created two lines in routes.rb. You just have to nest them like so:
map.resources :posts do |posts|
  posts.resources :comments
end

. . . which basically just says that comments will be accessed in the context of a particular post.

4. Modify the controller of the nested resource

The controller for the nested resource (comments) needs a few changes to account for the fact that comments will always be accessed in the context of a post.

a) add a before_filter:

before_filter(:get_post)
. . . .
private
def get_post
  @post = Post.find(params[:post_id])
end

b) amend the *_url calls in create and update actions

Look in the create and update actions for the comment_url calls. The call needs the nestee resource to properly generate the URL. In our case, it will look like:

From:  format.html { redirect_to comment_url(@comment) }

To:       format.html { redirect_to comment_url(@post,@comment) }

You'll need to make changes in both the update and create actions.

c) scope your Comment (nested class) finders to the Post (nestee class)

This is a security measure -- it ensures that the Comment ID passed to the controller really does represent a comment which belongs to the Post in question. It's all part of ensuring that the nested class is always dealt with in the context of a nestee class. In terms of your controller code, this means finding instances of comment.find and changing them to @post.comments.find. Don't forget the find :all in the index action!

d) Modify the nested class' create action

When you create a comment, it has to know what post it belongs to. This doesn't happen automatically. Add the bold line to the comments_controller's create action, so the first few lines of the create action looks like this:
def create
 @comment = Comment.new(params[:comment])
 @comment.post_id=@post.id
...
If you forget to do this step, the comment will save, but will have a null post_id, and will cause errors.

5. Modify the views

The only thing you need to change is a couple of *_path calls -- the same kind of updates you made in the controller. In each case you need to add the nestee class in *before* the nested class, so the method can properly generate the URL. For example, in the show.rhtml template, the link at the bottom is changed like so:

From: <%= link_to 'Show', comment_path(@comment) %>

To:      <%= link_to 'Show', comment_path(@post, @comment) %>

You need to make changes in three files for the nested resource (comments in our case):
  • edit.rhtml: the url in form_for, and the link_to 'show' at the bottom
  • show.rhtml: the link_to 'edit' at the bottom
  • index.rhtml: the link_to 'edit' and the link_to 'destroy'

6. Provide a link to the nested resource

Finally, let's add a link in views/posts/index.rhtml, so we can easily navigate to the comments associated with a posts: <%= link_to 'see comments', comments_path(post)%>

You're done!

That's all you need to do to get your CRUD scaffolding off the ground with nested resources. With the basics of a nested resource in place, you can now refactor the scaffolding to build the interface you need to. For example, you might want to get rid of the views/comments/index.rhtml, and list the comments on the post page instead. Enjoy the CRUD!

*Bonus Round*: DRY up finders in the Comments Controller

Notice that there are similar finders in (@post.comments.find(params[:id])) in your Comment's show, edit, update, and delete actions. You can DRY it up by removing those and adding the finder in your before_filter:
before_filter(:get_post)
. . . .
private
def get_post
  @post = Post.find(params[:post_id])
  @comment = @post.comments.find(params[:id]) if params[:id]
end

Learn more on REST

Comments

1

Tammer Saleh on Jan 10

Good article. It's also worth a mention that you should be using the model associations instead of the direct finders in your comments controller:

def index
  @post.comments
end

def show
@post.comments.find(params[:id])
end


..and so on.
2

Erik Kastner on Jan 16

Awesome stuff! I'll mention it down here since it's possibly beyond the scope of the post, but after you have the before_filter, you need to update your tests for the nested resource, they'll fail without a :post_id => on the requests.

3

Andre on Jan 16

Tammer: Thanks for the reminder, I integrated this into the example, together with a "bonus round" for DRYing up the finders.

Erik: good point re: testing the nested resources

4

Danno on Jan 22

Thanks Andre, this information is in the Second Edition of the Rails book but it's a bit scattered.

You also added some good tips!

5

Per Velschow on Jan 22

Interesting article! One thing has to strike you though. If this is what you will be doing most of the time when dealing with nested models, doesn't it seem like Rails should offer more direct support for this? Though the changes do not seem hard to make, manual, repetitive changes are always prone to errors.

So I was thinking something along the lines of adding has_many/belongs_to methods to the controllers similar to how it works for the models. The controller could even derive this relationship directly from the models. I haven't thought it through entirely. But one thing I imagine it would mean is that all find methods inside the comments controller would implicitly be scoped with a specific post. The named comment routes would also be auto-generated. Or they might even be implicit too; i.e. comment_path would always be in reference to the current post.

Just some vague ideas. :)

6

John on Jan 22

I agree with Per Velschow's comment completely. I've found myself writing some type of code to make controllers aware of parent-child relationships and act accordingly in about half of the rails apps I've written.

Now, don't get me wrong, because Rails enabled me to develop all of these apps in a small fraction of the time it would take in any other environment, but it would be great if this type boilerplate was handled by the framework through convention over configuration or whatever other clever means instead of requiring all the tedious, brittle and error-prone hand coding.

P.S. I previewed my comment then tried to post it when it looked fine and it was rejected by the flood control filter. I think the flood control filter should only activate on post, not on preview. Cheers.

7

Tom on Jan 23

Cool stuff, although you might want to check out inferred routes to take even more of the pain away.

8

Marton on Jan 23

This should be part of Rails generator.. So you could do this:
ruby script/generate scaffold_resource Post/Comment body:text

/MartOn

9

Michael on Jan 23

Thank you for the example. This is relevant to something I'm doing. I followed it through using Rails 1.2.1 and get an error related to the fact that post_id remains null after creating a comment. Is there something explicit that needs to happen for post_id to be set or should this be automatic?
Thank you,
Michael

10

Chris Cameron on Jan 23

Great article, this is helping me out in trying to convert some old Rails apps up to 1.2. One question, how do you deal with HABTM? Is the nesting similar? It seems like it will break down when you are dealing with HABTM.

11

Sean on Jan 24

When I run this code (after first setting up a db in mySQL and rake db:magrate (ing) it) I get the following error.

You have a nil object when you didn't expect it! The error occurred while evaluating nil.to_sym

Extracted source (around line #14):

11: <td><%=h post.title %></td>
12: <td><%=h post.body %></td>
13: <td><%= link_to 'Show', post_path(post) %></td>
14: <td><%= link_to 'see comments', comments_path(post)%></td>
15: <td><%= link_to 'Edit', edit_post_path(post) %></td>
16: <td><%= link_to 'Destroy', post_path(post), :confirm => 'Are you sure?', :method => :delete %></td>
17: </tr>

RAILS_ROOT: /home/sean/Documents/RubyProj/RailsProj/NestedCRUD/config/..

I can create a post but the minute I do and go back to 0.0.0.0:3000/posts I get this error.

Not sure why this is. I have rechecked the code example serveral times in the last couple of hours and my code is identical to the example code in the article as far as I can tell.

My setup:
rails --version: Rails 1.2.1
ruby --version: ruby 1.8.5 (2006-12-25 patchlevel 12) [x86_64-linux]nux]
mongrel_rails --version: Mongrel Web Server 1.0
mysql --version: mysql Ver 14.12 Distrib 5.0.18
gem --version 0.9.1

12

Jim Blanco on Jan 24

How would you handle a has and belongs to many situation? And what if you wanted to have the model mapped as a resource both in a nested relationship and by itself, so directly mapped? Would you use a :name_prefix? Great article by the way, helps a ton!

13

Andre Lewis on Jan 24

Michael, Sean: Thanks for pointing this out -- there was a missing step 4(d), which sets the post_id in the comment. I added it in the post above, so should be fixed.

Per, John, Marton: I totally agree, something like this should be an option in the generator. Of course, there's the issue of where to stop with the generated code too -- should it also produce HABTM, and HMT?

Sean: double-check your routes.rb per step 3. If you don't have the routes properly nested, you will get the error you posted.

On REST together with HABTM and has_many :through -- I'm going to put up a post on it when I have the chance to see through some different approaches. Stay tuned.

14

Stefan on Feb 21

Thanks Andre, nice article.

As a CRUD newbie I made a mistake and just added the lines under step 3 at the end instead of the beginning in routes.rb, so I've got 'No action responded to 1' error for /posts/1/comments because the default mapping was catching it first: map.connect ':controller/:action/:id'
I think it's good to know...

15

Hrvoje on Feb 28

add the foreign key to posts: t.column :post_id, :integer.

should be:

add the foreign key to comments: t.column :post_id, :integer.

16

ahoge on Oct 04

Hello, nice article, but shouldn't i change say..

respond_to do |format|
if @comment.save
flash[:notice] = 'Comment was successfully created.'

To:

respond_to do |format|
if @article.comments flash[:notice] = 'Comment was successfully created.'


Cheers.

17

René on Oct 20

Thank you!

18

Brian on Dec 05

There seems to be a good amount of examples like this one to create the REST services. I have you tried writing functional tests for this and have those working examples. Everyone always talks about test first, but none of the examples ever show tests first..In fact I haven't found any complete testing examples of a nested service. If you have those examples I think they would add a lot value and be really unique.

19

Matt on Dec 13

You could update the routes to use the new syntax.

post.resource, :has_many => [:comments]

20

Ryan Sandridge on Jan 16

OK, I'm commenting a year after the original post, so hopefully someone still watches this space. I'm curious if others have felt the need to expose an unnested route in addition to a nested route for some resources. Jamis discussed this somewhat, but fell short of discussing the implications in your controller.

The example that comes to mind immediately, in the context of this post, is you could imagine wanting a rss or atom feed to all comments. A natural route for this would be something like http://example.com/comments.atom.

If you go down the road Jamis suggests, then your controllers can get ugly very quickly. You may not want to expose all actions in both the nested and unnested cases. Do people have a best practice for how to keep their controllers nice and clean when exposing both nested and unnested access to the same resource? Perhaps a separate controller for each case?

Perhaps in the example case I mention above, where you really just want the index action available at the global scope, you don't use map.resource :comments, but create a named route map.comments 'comments', :controller => 'comments', :action => 'global_index' which routes to a special action 'global_index' in the comments controller. Thoughts?

Post a comment

 
This is so filters can reject the spam-bots. Thanks!