Tagging Multiple Models from Scratch in Rails 5

Introduction

In a previous article, Tagging from Scratch in Rails 5 we saw how we can tag a model. What if we need to tag multiple models? In this article, we will answer that question.

Tagging Multiple Models

To tag another model, you cannot do this:

class Tagging < ActiveRecord::Base
  belongs_to :tag
  belongs_to :episode
  belongs_to :article
end

You have to use polymorphic association like this:

class Tagging < ActiveRecord::Base
  belongs_to :tag
  belongs_to :taggable, :polymorphic => true
end

The tag class looks like this:

class Tag < ActiveRecord::Base
  has_many :taggings

  has_many :episodes, through: :taggings, source: :taggable, source_type: Episode
  has_many :articles, through: :taggings, source: :taggable, source_type: Article
end

Using Concerns to Encapsulate Tagging Functionality

We can move the tagging related methods in the episode.rb to app/models/concerns/taggable.rb:

module Taggable
  extend ActiveSupport::Concern

  included do
    has_many :taggings, :as => :taggable
    has_many :tags, :through => :taggings
  end  

  def tag_list
    tags.map(&:name).join(', ')
  end

  def tag_list=(names)
    self.tags = names.split(',').map do |name|
      Tag.where(name: name.strip).first_or_create!
    end
  end

  module ClassMethods
    def tag_counts
      Tag.select('tags.*, count(taggings.tag_id) as count').joins(:taggings).group('taggings.tag_id')
    end
  end
end

The episode model can now include the Taggable module.

class Episode < ActiveRecord::Base
  include Taggable

The article model can also include the Taggable module.

class Article < ActiveRecord::Base
  include Taggable

The only method we cannot move into this module is the following method:

def self.tagged_with(name)
  Tag.find_by!(name: name).episodes
end

The article model will also have a similar class method:

def self.tagged_with(name)
  Tag.find_by!(name: name).articles
end

Migrating the Database and the Production Data

The migration for this kind of association would be:

class CreateTaggings < ActiveRecord::Migration[5.0]
  def change
    create_table :taggings do |t|
      # other columns ...
      t.integer :taggable_id
      t.string  :taggable_type
      t.timestamps
    end

    add_index :taggings, [:taggable_type, :taggable_id]
  end
end

You can shorten it like this:

class CreateTaggings < ActiveRecord::Migration[5.0]
  def change
    create_table :taggings do |t|
      # other columns ...
      t.references :taggable, polymorphic: true, index: true
      t.timestamps
    end
  end
end

The existing taggings table has the columns: id, tag_id, episode_id. You can see it in the Rails console:

Tagging.first
  Tagging Load (0.6ms)  SELECT  `taggings`.* FROM `taggings`  ORDER BY `taggings`.`id` ASC LIMIT 1
 => #<Tagging id: 1, tag_id: 1, episode_id: 1, created_at: "2016-12-08 06:13:20", updated_at: "2016-12-08 06:13:20"> 

You can also see it in the schema.rb:

  create_table "taggings", force: :cascade do |t|
    t.integer  "tag_id"
    t.integer  "episode_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

We need to modify this table to have columns: id, tag_id, taggable_id, taggable_type. Generate a migration:

rails g migration tag_multiple_models

We need to rename the existing episode_id to taggable_id. Because, we can now tag any model and they will be distinguished by the value of taggable_type column (Article, Episode etc).

class TagMultipleModels < ActiveRecord::Migration
  def change
    rename_column :taggings, :episode_id, :taggable_id
    add_column :taggings, :taggable_type, :string
  end
end

We also need to migrate the existing data in production, so that all the columns for taggable_type is populated to Episode. Because the tagging functionality for article does not exist yet. We have not tagged any article in production. The migration file will now look like:

class TagMultipleModels < ActiveRecord::Migration
  def change
    rename_column :taggings, :episode_id, :taggable_id
    add_column :taggings, :taggable_type, :string
  end

  taggings = Tagging.all
  taggings.each do |t|
    t.taggable_type = 'Episode'
    t.save!
  end
end

We can simplify this by providing a default value for the new column.

class TagMultipleModels < ActiveRecord::Migration
  def change
    rename_column :taggings, :episode_id, :taggable_id
    add_column :taggings, :taggable_type, :string, :default => 'Episode'
  end  
end

Add the tag text field to article form partial:

<div class="field">
  <%= f.label :tag_list, "Tags (separated by commas)" %><br />
  <%= f.text_field :tag_list, :size=>"100"%>
</div>

Add tag_list to the permitted params in controller.

def article_params
  params.require(:article).permit(:title, :body, :tag_list)
end

You can now create a new article and add tags to it. This results in the foreign key constraint error:

Mysql2::Error: Cannot add or update a child row: a foreign key constraint fails (`chico_development`.`taggings`, CONSTRAINT `fk_rails_18a01188f6` FOREIGN KEY (`taggable_id`) REFERENCES `episodes` (`id`)): INSERT INTO `taggings` (`taggable_type`, `taggable_id`, `tag_id`, `created_at`, `updated_at`) VALUES ('Article', 4751, 541, '2017-06-06 02:53:45', '2017-06-06 02:53:45')

This is caused by:

add_foreign_key "taggings", "episodes", column: "taggable_id"

You can see this in schema.rb. We can remove foreign key constraint because we no longer have episode_id column. I did a git diff to check the changes in schema.rb. This line:

add_foreign_key "taggings", "episodes", column: "taggable_id"

was added while working on this feature. This is the cause of the problem. The syntax to remove foreign key in the migration:

remove_foreign_key :taggings, column: :taggable_id

You can see the table info in SequelPro:

CREATE TABLE `taggings` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tag_id` int(11) DEFAULT NULL,
  `taggable_id` int(11) DEFAULT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  `taggable_type` varchar(255) DEFAULT 'Episode',
  PRIMARY KEY (`id`),
  KEY `index_taggings_on_tag_id` (`tag_id`),
  KEY `index_taggings_on_taggable_id` (`taggable_id`),
  CONSTRAINT `fk_rails_18a01188f6` FOREIGN KEY (`taggable_id`) REFERENCES `episodes` (`id`),
  CONSTRAINT `fk_rails_9fcd2e236b` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1223 DEFAULT CHARSET=utf8;

The cause of this problem is this line:

CONSTRAINT `fk_rails_18a01188f6` FOREIGN KEY (`taggable_id`) REFERENCES `episodes` (`id`),

The migration file looks like this:

class TagMultipleModels < ActiveRecord::Migration
  def change
    rename_column :taggings, :episode_id, :taggable_id
    add_column :taggings, :taggable_type, :string, :default => 'Episode'
    remove_foreign_key :taggings, column: :taggable_id
  end  
end

Migrate the database.

rake db:migrate

This removes the foreign key on taggings.episode_id (we no longer need it because we renamed episode_id to taggable_id). See schema.rb, the foreign_key now disappears. This line:

add_foreign_key "taggings", "episodes", column: "taggable_id"

is now gone. Add the route to go to a specific tagged article:

get 'article_tagged/:tag', to: 'articles#index', as: :tag, :constraints  => { :tag => /[^\/]+/ }

Change the show page to use this route:

<% @article.tags.map(&:name).each do |tag| %> 
  <%= link_to(tag, tagged_article_path(tag), class: "button-tag") %>
<% end %>  

Change the articles index to handle the articles tag display :

def index
  @articles = if params[:tag]
    Article.tagged_with(params[:tag])
  else
    Article.order('id desc')
  end
end  

Problem in Production

Deployment to production failed with the error message:

 ActiveRecord::StatementInvalid: Mysql2::Error: Error on rename of './lafon_production/#sql-e2f_267' to './buffy_production/taggings' (errno: 150): ALTER TABLE `taggings` CHANGE `episode_id` `taggable_id` int(11) DEFAULT NULL

Resolution

Remove the foreign key first.

class TagMultipleModels < ActiveRecord::Migration
  def change
    remove_foreign_key :taggings, column: :taggable_id
    rename_column :taggings, :episode_id, :taggable_id
    add_column :taggings, :taggable_type, :string, :default => 'Episode'
  end  
end

This also failed with error message that there was no such foreign key. MySQL Server version: 5.5.37. On the server, we can see the details of the taggings table.

mysql> SHOW CREATE TABLE taggings;
| Table    | Create Table                                                                                                                                                                                                                  | taggings | CREATE TABLE `taggings` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tag_id` int(11) DEFAULT NULL,
  `episode_id` int(11) DEFAULT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `index_taggings_on_tag_id` (`tag_id`),
  KEY `index_taggings_on_episode_id` (`episode_id`),
  CONSTRAINT `fk_rails_18a01188f6` FOREIGN KEY (`episode_id`) REFERENCES `episodes` (`id`),
  CONSTRAINT `fk_rails_9fcd2e236b` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1881 DEFAULT CHARSET=utf8 |

The fix involves removing this:

  KEY `index_taggings_on_episode_id` (`episode_id`),

So the migration that worked is:

class TagMultipleModels < ActiveRecord::Migration
  def change
    remove_foreign_key :taggings, column: :episode_id
    rename_column :taggings, :episode_id, :taggable_id
    add_column :taggings, :taggable_type, :string, :default => 'Episode'
  end  
end

References


Related Articles


Create your own user feedback survey