Lugo Labs

Tag your ActiveRecord models

Our task today is to add tags to the Article models of a blog application written in Ruby on Rails. The user should be able to enter a list of words separated by comma; the app should then create tags from the words entered if they don't exist, and link them to the article.

The Models

Let's start by creating firs the Tag model:

sh
bundle exec rails g model tag name:string

The Tag model has only one attribute, name, which will be extracted from the words entered by the user.

Since the relationship between articles and tags is many to many, we also need an intermediary model, which we'll call Tagging:

sh
bundle exec rails g model tagging article:belongs_to tag:belongs_to

The belongs_to directive will make Rails to add correct indexes, foreign keys, and macros to the Tagging model.

We then run the migration to update the database:

sh
bundle exec rake db:migrate

The Concern

To link the Article with the Tag we'll make use of ActiveSupport::Concern, a module we can include inside the Article model.

ruby
# app/models/concerns/taggable.rb

module Taggable
  extend ActiveSupport::Concern

  included do
    has_many :taggings
    has_many :tags, :through => :taggings
    after_save :save_tags
  end
end

When using concerns, we need to add the ActiveRecord macros inside the included block. The first two has_many create the many to many relationship between articles and tags.

The after_save callback saves the tags when saving the article. Let's add that method now:

ruby
# app/models/concerns/taggable.rb

private

  def save_tags
    tag_names.split(',').each do |name|
      tag = Tag.find_or_create_by(name: name.strip)
      tags << tag unless tags.exists?(tag.id)
    end
    clean_tags
  end

  def clean_tags
    names = tag_names.split(',').map { |n| n.strip }
    tags.each do |tag|
      tags.destroy(tag) unless names.include?(tag.name)
    end
  end

Here we split the tag names and save them into our database if they don't exist. We also remove any tags that are no longer in the list provided, useful when we edit the article's tags.

The tag_names attribute doesn't exist yet, so let's create that now:

ruby
# app/models/concerns/taggable.rb

def tag_names
  @tag_names.blank? ? tags.map(&:name).join(', ') : @tag_names
end

def tag_names=(names)
  @tag_names = names
end

The tag_names is a reader/writer attribute, which will not exist as such in the database (as we want different Tag models). They will be useful in our article view form.

At the end, we include the Taggable module to our Article model:

ruby
class Article < ActiveRecord::Base
  include Taggable
end

The View

Assuming we have a normal Rails style form for the Article, we add the following to it:

html
<div class="field">
  <%= f.label :tag_names %><br>
  <%= f.text_field :tag_names %>
</div>

When we run the app, we should now be able to add tags separated by comma (e.g. Ruby on Rails, JavaScript, CSS), and they will create tags attached to the article.

Happy coding!