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
- 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. - rake db:migrate db:fixtures:load test
- 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.
- We have to update the routes for URLs to let Rails know how to connect articles to comments
- We have to update the model classes for the two classes, to let them know about the relationship
- We have to update the Comments_Controller_ to refer to the correct article when it finds, creates or modifies a comment.
- We have to include the information about the comments in the article views.
Here we go:
Lets start with routing. The file
config/routes.rb
tells us how our application URLs are structured. Removemap.resource :comments
, and addcomments
as a “nested route” instead:map.resources :articles do |article| article.resources :comments, :name\_prefix => nil end
This change means that you will find the comments like this: http://localhost:3000/articles/article-id/comments/
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) %>
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.
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
def comment\_path(comment) url\_for :controller => "comments", :action => "show", :article\_id => comment.article, :id => comment end def edit\_comment\_path(comment) url\_for :controller => "comments", :action => "edit", :article\_id => comment.article, :id => comment end
You now need to inform the model classes about each other via belongs_to and has_many associations. Update
app/models/article.rb
andapp/models/comment.rb
:class Comment < ActiveRecord::Base belongs\_to :article end class Article < ActiveRecord::Base has\_many :comments end
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.
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 theclass
line.Implement the filter. Add the following to the very end of the class file, just before the final “end”:
protected def find\_article @article = Article.find(params[:article\_id]) end
You can now replace every
find
andnew
on the Comment class with similar code in the article model. Replace every place where the code saysComment.find(...)
with@article.comments.find(...)
, and every place the code saysComment.new(...)
with@article.comments.new(...)
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.
def comment\_url(comment) url\_for :controller => "comments", :action => "show", :article\_id => comment.article, :id => comment end
Finally, we should never let the user give
article_id
in the input forms. To avoid this, you can add the line[attr_protected](http://api.rubyonrails.org/classes/ActiveRecord/Base.html#M001390) :article_id
toapp/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.
- 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 ofapp/view/comments/index.html.erb
- 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. - 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 ofapp/view/comments/index.html.erb
. - 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 %>
- 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.
- 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. - One test still doesn’t run. In
test_should_create_comment
assert_redirected_to comment_path(assigns(:comment))
should be changed toassert_redirected_to comment_path(:id => assigns(:comment), :article_id => assigns(:article))
- 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!
Comments:
[keik] - Mar 5, 2008
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
Johannes Brodwall - Mar 7, 2008
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”?