Rails intro #2: One-to-many relationships

In my last article, I showed you how to get started with your Rails application. The result of the simple commands, rails blogdemo; cd blogdemo; ruby script/generate scaffold article title:string author:string content:text; rake db:migrate; ruby script/server was that you had your own simple blog up and running. The blog support articles each of which have a title, an author, and text content. This initial model was generated for us with no editing on our part.

In this second article, I will expand the blog to allow comments to our articles. This will require us to get our hands a bit more dirty with the Rails code, but it won’t be too bad. And even better: If you complete the instructions in this article, you can call yourself a Rails programmer!

Are you ready to dive in head first?

The initial structure

  1. Like in the example with the article model class, we create a comment model. ruby script/generate scaffold comment article_id:integer title:string author:string body:text. Notice the article_id column, which will be our foreign key to the article.
  2. rake db:migrate db:fixtures:load test
  3. You can now see the comments at http://localhost:3000/comments/

Connecting the comments to articles

To get the articles and comments connected, we need to do four things. I will take you thought the process in detail.

  1. We have to update the routes for URLs to let Rails know how to connect articles to comments
  2. We have to update the model classes for the two classes, to let them know about the relationship
  3. We have to update the CommentsController to refer to the correct article when it finds, creates or modifies a comment.
  4. We have to include the information about the comments in the article views.

Here we go:

  1. Lets start with routing. The file config/routes.rb tells us how our application URLs are structured. Remove map.resource :comments, and add comments as a “nested route” instead:

    This change means that you will find the comments like this: http://localhost:3000/articles/article-id/comments/
  2. Update app/view/articles/show.html.erb so we can get the comments related to an article. Add the following code:<%= link_to "Comments", comments_path(@article) %>
  3. http://localhost:3000/comments/ no longer works. Instead: Select an article, and click the new Comments link. This gives an error, because the routes we generated don’t take the relationship between articles and comments into consideration.
  4. The best way to fix this is to change the methods we use to find comments. We can do this by updating the view helper in app/helpers/comments_helper.rb
  5. You now need to inform the model classes about each other via belongs_to and has_many associations. Update app/models/article.rb and app/models/comment.rb:

Updating the controller

If you click on the Comments link, you will now be rewarded with a seemingly meaningful set of comments for the current article. But appearances can be deceiving. We have still not made the controllers aware of the fact that only the comments for the current article should be displayed. If you click around in the application, it will soon become apparent that all comments are displayed under all articles! We need to update the controller to restrict to only comments for the current article.

  1. Update app/controllers/comments_controller.rb, and make sure we always have an article available. This is usually done through a "before_filter". Add the following line to the beginning of the file: before_filter :find_article, right after the class line.
  2. Implement the filter. Add the following to the very end of the class file, just before the final "end":
  3. You can now replace every find and new on the Comment class with similar code in the article model. Replace every place where the code says Comment.find(...) with @article.comments.find(...), and every place the code says Comment.new(...) with @article.comments.new(...)
  4. We also need to let the controller know how to redirect to a created or updated view. Add the following right after the end of the find_article code above.
  5. Finally, we should never let the user give article_id in the input forms. To avoid this, you can add the line attr_protected :article_id to app/model/comment.rb

At this stage, creating, viewing, updating and deleting comments should all work as expected. However, before we turn to the view, you might have noticed a strange thing with our test data. The comments generated by running rake db:fixtures:load are not connected to the correct articles. The place to fix this is in test/fixtures/comments.yml, and the way to fix it is a new feature with Rails 2.0:

In test/fixtures/comments.yml, change the lines that read article_id: 1 into article: one. Regenerate the test data by running rake db:fixtures:load. Both articles should now be attached to your first article.

Integrating the views

Finally, we want to improve the way all this looks. Polishing the view will be the topic of my next article, so for now, let's just mash the information together.

  1. We want to display all the comments in the details view for an article. Replace the link we created to the comments in app/view/articles/show.html.erb with the full contents of app/view/comments/index.html.erb
  2. Still in app/view/articles/show.html.erb, replace <% for comment in @comments %> with <% for comment in @article.comments %< and <%= link_to 'New comment', new_comment_path %> with <%= link_to "New comment", new_comment_path(@article) %>. Your articles should now display the corresponding comments.
  3. We also want our users to be able to add new comments when they view the article. Still in app/view/articles/show.html.erb, replace the "New comment" link with the full contents of app/view/comments/index.html.erb.
  4. Replace <% form_for(@comment) do |f| %> with <% form_for :comment, :url => comments_path(@article) do |f| %> and remove the link <%= link_to 'Back', comments_path %>
  5. If you look at the page for an article now, you will be rewarded with a functional, but ugly page that despite its appearance displays the article, all comments for the article, and allows the users to add new comments.

Fixing the tests

Before we leave, lets make sure that our tests still pass. Run rake test. You tests should fail at this point. This is because the tests don't supply the article_id attribute CommentsController now requires.

  1. All post and get requests in test/functional/comments_controller_test.rb need have an argument :article_id => articles(:one) added to them. For example: get :index, :article_id => articles(:one). Do this for all the get and post requests in the test.
  2. One test still doesn't run. In test_should_create_comment assert_redirected_to comment_path(assigns(:comment)) should be changed to assert_redirected_to comment_path(:id => assigns(:comment), :article_id => assigns(:article))
  3. The tests should now pass.

Conclusion

This more lengthy post takes you through all the steps that are needed to create a web application that displays a one-to-many relationship, namely that of one article having many comments. We had to update both the URL routing to make comments a nested resource, the model class to associate the comments and articles, the controller to only find comments on the current articles and the article views to include the comment data, but every change is fairly simple.

Until this point, I have assumed that you as a reader don't know much about Rails. After completing this article, you have touched quite a few of the core concepts of Rails already. If you found the ride too confusing because I didn't provide a lot of details about the way a Rails application works, I would very much like to know it.

The next article in this series will clean up the view and add AJAX support.

Stay tuned, we are just getting started!

Creative Commons License
Rails intro #2: One-to-many relationships by Johannes Brodwall, unless otherwise expressly stated, is licensed under a Creative Commons Attribution 3.0 Unported License.

About Johannes Brodwall

Johannes is the Oslo based Chief Scientist for the Sri Lanka based company Exilesoft. He trains distributed teams and contributes to projects halfway across the world. He is an active contributor to the programmer community in Oslo and Sri Lanka and a veteran speaker in Norway and abroad.
This entry was posted in Ruby-on-Rails. Bookmark the permalink.
  • keik

    i get the following error on step 2on updating the controller

    app/controllers/comments_controller.rb:89: formal argument cannot be an instance variable

    def find_article
    @article = Article.find(params[:article_id])
    end

  • keik

    i get the following error on step 2on updating the controller

    app/controllers/comments_controller.rb:89: formal argument cannot be an instance variable

    def find_article
    @article = Article.find(params[:article_id])
    end

  • http://brodwall.com/johannes/ Johannes Brodwall

    Hi, Keik

    I don't get the same error, and I'm wondering if you might've made a mistake in copying the code. The error message is something you get if you say

    def method(@param) …

    Or

    do |@param| …

    or

    { |@param| …

    Is it possible that you have an extra “(” after “find_article”?

  • http://brodwall.com/johannes/ Johannes Brodwall

    Hi, Keik

    I don’t get the same error, and I’m wondering if you might’ve made a mistake in copying the code. The error message is something you get if you say

    def method(@param) …

    Or

    do |@param| …

    or

    { |@param| …

    Is it possible that you have an extra “(” after “find_article”?