acts_as_taggable per user tagging

David Heinemeier Hansson created the acts_as_taggable Ruby on Rails plugin to allow tagging of any Active Record object. This is great for tagging objects application-wide, but I required user specific tags for my Rails app. Hopefully the following outlines the exact changes required to add the same functionality to your own Rails app – post a comment if you have any problems.

Note: There is also an acts_as_taggable gem which is different than the plugin. The acts_as_taggable plugin is only available for Rails 1.1.

Install acts_as_taggable plugin

script/plugin install acts_as_taggable

Create database tables

The following are mysql specific create table definitions:

-- tags
DROP TABLE IF EXISTS tags;
CREATE TABLE IF NOT EXISTS tags (
  id int(11) unsigned NOT NULL auto_increment,
  name varchar(255) NOT NULL,
  created_at datetime NOT NULL,
  updated_at datetime NOT NULL,
  PRIMARY KEY (id)
) TYPE=InnoDB;

-- taggings
DROP TABLE IF EXISTS taggings;
CREATE TABLE taggings (
  id int(11) unsigned NOT NULL auto_increment,
  tag_id int(11) unsigned NOT NULL,
  taggable_id int(11) unsigned NOT NULL,
  taggable_type varchar(255),
  user_id int(11) unsigned NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

The above taggings table differs from the normal acts_as_taggable plugin requirement by adding a user_id column to associate a tagging with an individual user. I am assuming that your Rails application already has a users table (I am using the login_engine to provide user authentication).

Update Rails objects

Add acts_as_taggable to the class you want to be able to tag

class ItemToTag < ActiveRecord::Base
  acts_as_taggable
end
class User < ActiveRecord::Base
  has_many :taggings
  has_many :tags, :through => :taggings, :select => "DISTINCT tags.*"
end

Modify acts_as_taggable Plugin

To add per-user taggings we need to modify the acts_as_taggable plugin itself. First we add belongs_to :user to associate a tagging with a user.

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

  belongs_to :user

  def self.tagged_class(taggable)
    ActiveRecord::Base.send(:class_name_of_active_record_descendant, taggable.class).to_s
  end

  def self.find_taggable(tagged_class, tagged_id)
    tagged_class.constantize.find(tagged_id)
  end
end

Next replace the existing on method with the following in the Tag class (tag.rb file).

def on(taggable, user)
  tagging = taggings.create :taggable => taggable, :user => user
end

Update acts_as_taggable.rb class by replacing each of the following methods with those given below:

def find_tagged_with(list, user)
  find_by_sql([
    "SELECT #{table_name}.* FROM #{table_name}, tags, taggings " +
    "WHERE #{table_name}.#{primary_key} = taggings.taggable_id " +
    "AND taggings.user_id = ? " +
    "AND taggings.taggable_type = ? " +
    "AND taggings.tag_id = tags.id AND tags.name IN (?)",
    user.id, acts_as_taggable_options[:taggable_type], list
  ])
end
def tag_with(list, user)
  Tag.transaction do
   Tagging.destroy_all(" taggable_id = #{self.id} and taggable_type = '#{self.class}' and user_id = #{user.id}")

    Tag.parse(list).each do |name|
      if acts_as_taggable_options[:from]
        send(acts_as_taggable_options[:from]).tags.find_or_create_by_name(name).on(self, user)
      else
        Tag.find_or_create_by_name(name).on(self, user)
      end
    end
  end
end
def tag_list
  tags.collect { |tag| tag.name.include?(" ") ? "'#{tag.name}'" : tag.name }.join(" ")
end

Usage

item_to_tag.tag_with('ruby rails', current_user)
item_to_tag.save
item_to_tag.tag_list  # => 'ruby rails'
current_user.tags     # => 'ruby rails'
ItemToTag.find_tagged_with('ruby', current_user)

Note: The tag_with is destructive, it deletes all previous tags.

Update

Previously the tags collection for the User object would return ALL taggings (with duplicates if multiple objects had been tagged with the same tag). Thanks to Ian Li who kindly suggested the use of :select => "DISTINCT tags.*" for the tags has_many relationship in the User class, duplicates are now filtered at the database.

Update II

I’ve replaced the taggings.destroy_all with the following in the tag_with method. This is in response to feedback from Bryan and Anna regarding the destructive nature of the previous tag_with method. Thanks for the comments.

Tagging.destroy_all(" taggable_id = #{self.id} and taggable_type = '#{self.class}' and user_id = #{user.id}")

Since writing this post back in April 2006 I’ve started a new project with slightly different tagging requirements, I’ll create a new article when I’m done and update the content here.

References

Pretty much all of this information came from the Ruby on Rails wiki and Rails Weenie so props to them.


About this entry