I3: Tagging
In this iteration we'll add the ability to tag articles for organization and navigation.
First we need to think about what a tag is and how it'll relate to the Article model. If you're not familiar with tags, they're commonly used in blogs to assign the article to one or more categories.
For instance, if I write an article about a feature in Ruby on Rails, I might want it tagged with all of these categories: "ruby", "rails" and "programming". That way if one of my readers is looking for more articles about one of those topics they can click on the tag and see a list of my articles with that tag.
Understanding the Relationship
What is a tag? We need to figure that out before we can create the model. First, a tag must have a relationship to an article so they can be connected. A single tag, like "ruby" for instance, should be able to relate to many articles. On the other side of the relationship, the article might have multiple tags (like "ruby", "rails", and "programming" as above) - so it's also a many relationship. Articles and tags have a many-to-many relationship.
Many-to-many relationships are tricky because we're using an SQL database. If an Article "has many" tags, then we would put the foreign key article_id
inside the tags
table - so then a Tag would "belong to" an Article. But a tag can connect to many articles, not just one. We can't model this relationship with just the articles
and tags
tables.
When we start thinking about the database modeling, there are a few ways to achieve this setup. One way is to create a "join table" that just tracks which tags are connected to which articles. Traditionally this table would be named articles_tags
and Rails would express the relationships by saying that the Article model has_and_belongs_to_many
Tags, while the Tag model has_and_belongs_to_many
Articles.
Most of the time this isn't the best way to really model the relationship. The connection between the two models usually has value of its own, so we should promote it to a real model. For our purposes, we'll introduce a model named "Tagging" which is the connection between Articles and Tags. The relationships will setup like this:
- An Article
has_many
Taggings - A Tag
has_many
Taggings - A Tagging
belongs_to
an Article andbelongs_to
a Tag
Making Models
With those relationships in mind, let's design the new models:
- Tag
name
: A string
- Tagging
tag_id
: Integer holding the foreign key of the referenced Tagarticle_id
: Integer holding the foreign key of the referenced Article
Note that there are no changes necessary to Article because the foreign key is stored in the Tagging model. So now lets generate these models in your terminal:
$ bin/rails generate model Tag name:string
$ bin/rails generate model Tagging tag:references article:references
$ bin/rake db:migrate
Expressing Relationships
Now that our model files are generated we need to tell Rails about the relationships between them. For each of the files below, add these lines:
In app/models/article.rb
:
has_many :taggings
In app/models/tag.rb
:
has_many :taggings
In app/models/tagging.rb
:
belongs_to :tag
belongs_to :article
After Rails had been around for awhile, developers were finding this kind of relationship very common. In practical usage, if I had an object named article
and I wanted to find its Tags, I'd have to run code like this:
tags = article.taggings.collect{|tagging| tagging.tag}
That's a pain for something that we need commonly.
An article has a list of tags through the relationship of taggings. In Rails we can express this "has many" relationship through an existing "has many" relationship. We will update our article model and tag model to express that relationship.
In app/models/article.rb
:
has_many :taggings
has_many :tags, through: :taggings
In app/models/tag.rb
:
has_many :taggings
has_many :articles, through: :taggings
Now if we have an object like article
we can just ask for article.tags
or, conversely, if we have an object named tag
we can ask for tag.articles
.
To see this in action, start the bin/rails console
and try the following:
$ a = Article.first
$ a.tags.create name: "tag1"
$ a.tags.create name: "tag2"
$ a.tags
=> [#<Tag id: 1, name: "tag1", created_at: "2012-11-28 20:17:55", updated_at: "2012-11-28 20:17:55">, #<Tag id: 2, name: "tag2", created_at: "2012-11-28 20:31:49", updated_at: "2012-11-28 20:31:49">]
An Interface for Tagging Articles
The first interface we're interested in is within the article itself. When I write an article, I want to have a text box where I can enter a list of zero or more tags separated by commas. When I save the article, my app should associate my article with the tags with those names, creating them if necessary.
Add the following to our existing form in app/views/articles/_form.html.erb
:
<p>
<%= f.label :tag_list %><br />
<%= f.text_field :tag_list %>
</p>
With that added, try to create a new article in your browser and you should see this error:
NoMethodError in Articles#new
Showing app/views/articles/_form.html.erb where line #14 raised:
undefined method `tag_list' for #
An Article doesn't have an attribute or method named tag_list
. We made it up in order for the form to display related tags, but we need to add a method to the article.rb
file like this:
def tag_list
tags.join(", ")
end
Back in your console, find that article again, and take a look at the results of tag_list
:
$ reload!
$ a = Article.first
$ a.tag_list
=> "#<Tag:0x007fe4d60c2430>, #<Tag:0x007fe4d617da50>"
That is not quite right. What happened?
Our array of tags is an array of Tag instances. When we joined the array Ruby called the default #to_s
method on every one of these Tag instances. The default #to_s
method for an object produces some really ugly output.
We could fix the tag_list
method by:
- Converting all our tag objects to an array of tag names
- Joining the array of tag names together
def tag_list
self.tags.collect do |tag|
tag.name
end.join(", ")
end
Another alternative is to define a new Tag#to_s
method which overrides the default:
class Tag < ActiveRecord::Base
has_many :taggings
has_many :articles, through: :taggings
def to_s
name
end
end
Now, when we try to join our tags
, it'll delegate properly to our name
attribute. This is because #join
calls #to_s
on every element of the
array.
Your form should now show up and there's a text box at the bottom named "Tag list". Enter content for another sample article and in the tag list enter 'ruby, technology'. Click save. It.... worked?
But it didn't. Click 'edit' again, and you'll see that we're back to the #<Tag...
business, like before. What gives?
Started PATCH "/articles/1" for 127.0.0.1 at 2013-07-17 09:25:20 -0400
Processing by ArticlesController#update as HTML
Parameters: {"utf8"=>"", "authenticity_token"=>"qs2M71Rmb64B7IM1ASULjlI1WL6nWYjgH/eOu8en+Dk=", "article"=>{"title"=>"Sample Article", "body"=>"This is the text for my article, woo hoo!", "tag_list"=>"ruby, technology"}, "commit"=>"Update Article", "id"=>"1"}
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT 1 [["id", "1"]]
Unpermitted parameters: tag_list
Unpermitted parameters? Oh yeah! Strong Parameters has done its job, saving us from parameters we don't want. But in this case, we do want that parameter. Open up your app/helpers/articles_helper.rb
and fix the article_params
method:
def article_params
params.require(:article).permit(:title, :body, :tag_list)
end
If you go back and put "ruby, technology" as tags, and click save, you'll get this new error:
ActiveRecord::UnknownAttributeError in ArticlesController#create
unknown attribute: tag_list
What is this all about? Let's start by looking at the form data that was posted when we clicked SAVE. This data is in the terminal where you are running the rails server. Look for the line that starts "Processing ArticlesController#create", here's what mine looks like:
Processing ArticlesController#create (for 127.0.0.1) [POST]
Parameters: {"article"=>{"body"=>"Yes, the samples continue!", "title"=>"My Sample", "tag_list"=>"ruby, technology"}, "commit"=>"Save", "authenticity_token"=>"xxi0A3tZtoCUDeoTASi6Xx39wpnHt1QW/6Z1jxCMOm8="}
The field that's interesting there is the "tag_list"=>"technology, ruby"
. Those are the tags as I typed them into the form. The error came up in the create
method, so let's peek at app/controllers/articles_controller.rb
in the create
method. See the first line that calls Article.new(article_params)
? This is the line that's causing the error as you could see in the middle of the stack trace.
Since the create
method passes all the parameters from the form into the Article.new
method, the tags are sent in as the string "technology, ruby"
. The new
method will try to set the new Article's tag_list
equal to "technology, ruby"
but that method doesn't exist because there is no attribute named tag_list
.
There are several ways to solve this problem, but the simplest is to pretend like we have an attribute named tag_list
.
We can define the tag_list=
method inside article.rb
like this: (do not delete your original tag_list method)
def tag_list=(tags_string)
end
Just leave it blank for now and try to resubmit your sample article with tags. It goes through!
Not So Fast
Did it really work? It's hard to tell. Let's jump into the console and have a look.
$ a = Article.last
$ a.tags
I bet the console reported that a
had []
tags -- an empty list. (It also probably said something about an ActiveRecord::Associations::CollectionProxy
😉 ) So we didn't generate an error, but we didn't create any tags either.
We need to return to the Article#tag_list=
method in article.rb
and do some more work.
The Article#tag_list=
method accepts a parameter, a string like "tag1, tag2, tag3" and we need to associate the article with tags that have those names. The pseudo-code would look like this:
- Split the tags_string into an array of strings with leading and trailing whitespace removed (so
"tag1, tag2, tag3"
would become["tag1","tag2","tag3"]
- For each of those strings...
- Ensure each one of these strings are unique
- Look for a Tag object with that name. If there isn't one, create it.
- Add the tag object to a list of tags for the article
- Set the article's tags to the list of tags that we have found and/or created.
The first step is something that Ruby does very easily using the String#split
method. Go into your console and try "tag1, tag2, tag3".split. By default it split on the space character, but that's not what we want. You can force split to work on any character by passing it in as a parameter, like this: "tag1, tag2, tag3".split(",")
.
Look closely at the output and you'll see that the second element is " tag2"
instead of "tag2"
-- it has a leading space. We don't want our tag system to end up with different tags because of some extra (non-meaningful) spaces, so we need to get rid of that. The String#strip
method removes leading or trailing whitespace -- try it with " my sample ".strip
. You'll see that the space in the center is preserved.
So first we split the string, and then trim each and every element and collect those updated items:
$ "programming, Ruby, rails".split(",").collect{|s| s.strip.downcase}
The String#split(",")
will create the array with elements that have the extra spaces as before, then the Array#collect
will take each element of that array and send it into the following block where the string is named s
and the String#strip
and String#downcase
methods are called on it. The downcase
method is to make sure that "ruby" and "Ruby" don't end up as different tags. This line should give you back ["programming", "ruby", "rails"]
.
Lastly, we want to make sure that each and every tag in the list is unique. Array#uniq
allows us to remove duplicate items from an array.
$ "programming, Ruby, rails, rails".split(",").collect{|s| s.strip.downcase}.uniq
Now, back inside our tag_list=
method, let's add this line:
tag_names = tags_string.split(",").collect{|s| s.strip.downcase}.uniq
So looking at our pseudo-code, the next step is to go through each of those tag_names
and find or create a tag with that name. Rails has a built in method to do just that, like this:
tag = Tag.find_or_create_by(name: tag_name)
And finally we need to collect up these new or found new tags and then assign them to our article.
def tag_list=(tags_string)
tag_names = tags_string.split(",").collect{|s| s.strip.downcase}.uniq
new_or_found_tags = tag_names.collect { |name| Tag.find_or_create_by(name: name) }
self.tags = new_or_found_tags
end
Testing in the Console
Go back to your console and try these commands:
$ reload!
$ article = Article.create title: "A Sample Article for Tagging!", body: "Great article goes here", tag_list: "ruby, technology"
$ article.tags
You should get back a list of the two tags. If you'd like to check the other side of the Article-Tagging-Tag relationship, try this:
$ tag = article.tags.first
$ tag.articles
And you'll see that this Tag is associated with just one Article.
Adding Tags to our Display
According to our work in the console, articles can now have tags, but we haven't done anything to display them in the article pages.
Let's start with app/views/articles/show.html.erb
. Right below the line that displays the article.title
, add these lines:
<p>
Tags:
<% @article.tags.each do |tag| %>
<%= link_to tag.name, tag_path(tag) %>
<% end %>
</p>
Refresh your view and...BOOM:
NoMethodError in Articles#show
Showing app/views/articles/index.html.erb where line #6 raised:
undefined method `tag_path' for #
The link_to
helper is trying to use tag_path
from the router, but the router doesn't know anything about our Tag object. We created a model, but we never created a controller or route. There's nothing to link to -- so let's generate that controller from your terminal:
$ bin/rails generate controller tags
Then we need to add tags as a resource to our config/routes.rb
, it should look like this:
Rails.Application.routes.draw do
root to: 'articles#index'
resources :articles do
resources :comments
end
resources :tags
end
Refresh your article page and you should see tags, with links, associated with this article.
Listing Articles by Tag
The links for our tags are showing up, but if you click on them you'll see our old friend "No action responded to show." error.
Open app/controllers/tags_controller.rb
and define a show action:
def show
@tag = Tag.find(params[:id])
end
Then create the show template app/views/tags/show.html.erb
:
<h1>Articles Tagged with <%= @tag.name %></h1>
<ul>
<% @tag.articles.each do |article| %>
<li><%= link_to article.title, article_path(article) %></li>
<% end %>
</ul>
Refresh your view and you should see a list of articles with that tag. Keep in mind that there might be some abnormalities from articles we tagged before doing our fixes to the tag_list=
method. For any article with issues, try going to its edit
screen, saving it, and things should be fixed up. If you wanted to clear out all taggings you could do Tagging.destroy_all
from your console.
Listing All Tags
We've built the show
action, but the reader should also be able to browse the tags available at http://localhost:3000/tags
. I think you can do this on your own. Create an index
action in your tags_controller.rb
and an index.html.erb
in the corresponding views folder. Look at your articles_controller.rb
and Article index.html.erb
if you need some clues.
Now that we can see all of our tags, we also want the capability to delete them.
I think you can do this one on your own too. Create a destroy
action in your
tags_controller.rb
and edit the index.html.erb
file you just created. Look
at your articles_controller.rb
and Article show.html.erb
if you need some
clues.
With that, a long Iteration 3 is complete!
Saving to GitHub.
Woah! The tagging feature is now complete. Good on you. You're going to want to push this to the repo.
$ git add .
$ git commit -m "Tagging feature completed"
$ git push