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 and belongs_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 Tag
    • article_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