Simplifying Folksonomy with the acts_as_taggable

Problem

You want to make it easier to assign tags to your content and then to search for records by their tags. You may also have more than one model in your application that you want to associate with tags.

Solution

Install and modify the acts_as_taggable plug-in, especially if you have more than one model that needs tagging. The plug-in ships with a broken instance method definition, but it can easily be modified to work as advertised. Start by downloading and installing the plug-in into your application:

$ ruby script/plugin install acts_as_taggable

The tag_list instance method needs to be defined as follows for it to work correctly. The tag_with method has also been customized to behave more naturally when assigning tags to objects.

vendor/plugins/acts_as_taggable/lib/acts_as_taggable.rb:

module ActiveRecord
 module Acts #:nodoc:
 module Taggable #:nodoc:
 def self.included(base)
 base.extend(ClassMethods) 
 end
 module ClassMethods
 def acts_as_taggable(options = {})
 write_inheritable_attribute(:acts_as_taggable_options, {
 :taggable_type => ActiveRecord::Base.\
 send(:class_name_of_active_record_descendant, self).to_s,
 :from => options[:from]
 })
 class_inheritable_reader :acts_as_taggable_options
 has_many :taggings, :as => :taggable, :dependent => true
 has_many :tags, :through => :taggings
 include ActiveRecord::Acts::Taggable::InstanceMethods
 extend ActiveRecord::Acts::Taggable::SingletonMethods 
 end
 end
 module SingletonMethods
 def find_tagged_with(list)
 find_by_sql([
 "SELECT #{table_name}.* FROM #{table_name}, tags, taggings " +
 "WHERE #{table_name}.#{primary_key} = taggings.taggable_id " +
 "AND taggings.taggable_type = ? " +
 "AND taggings.tag_id = tags.id AND tags.name IN (?)",
 acts_as_taggable_options[:taggable_type], list
 ])
 end
 end
 module InstanceMethods
 def tag_with(list)
 Tag.transaction do
 curr_tags = self.tag_list
 taggings.destroy_all
 uniq_tags = (list + ' ' + curr_tags).split(/\s+/).uniq.join(" ")
 Tag.parse(uniq_tags).sort.each do |name|
 if acts_as_taggable_options[:from]
 send(acts_as_taggable_options[:from]).tags.\
 find_or_create_by_name(name).on(self)
 else
 Tag.find_or_create_by_name(name).on(self)
 end
 end
 end
 end
 def tag_list
 self.reload
 tags.collect do |tag|
 tag.name.include?(" ") ? "'#{tag.name}'" : tag.name
 end.join(" ")
 end
 end
 end
 end end

Your application contains articles and announcements. You want the ability to tag objects from both models. Start by creating a migration to build these tables:

db/migrate/001_add_articles_add_announcements.rb:

class AddArticles < ActiveRecord::Migration
 def self.up
 create_table :articles do |t|
 t.column :title, :text
 t.column :body, :text
 t.column :created_on, :date
 t.column :updated_on, :date
 end
 create_table :announcements do |t|
 t.column :body, :text
 t.column :created_on, :date
 t.column :updated_on, :date
 end
 end
 def self.down
 drop_table :articles
 drop_table :announcements
 end end

Next, generate a migration to set up the necessary tags and taggings tables, as required by the plug-in.

db/migrate/002_add_tag_support.rb:

class AddTagSupport < ActiveRecord::Migration
 def self.up
 # Table for your Tags
 create_table :tags do |t|
 t.column :name, :string
 end
 create_table :taggings do |t|
 t.column :tag_id, :integer
 # id of tagged object
 t.column :taggable_id, :integer
 # type of object tagged
 t.column :taggable_type, :string
 end
 end
 def self.down
 drop_table :tags
 drop_table :taggings
 end end

Finally, in article.rb and announcement.rb, declare both the Article and Announcement models as taggable:

app/models/article.rb:

class Article < ActiveRecord::Base
 acts_as_taggable end

app/models/announcement.rb:

class Announcement < ActiveRecord::Base
 acts_as_taggable end

You can now use the tag_with method provided by the plug-in to associate tags with both Article and Announcement objects. You can view the assigned tags of an object with the tag_list method.

Once you have some content associated with tags, you can use those tags to help users search for relevant content. Use find_tagged_with to find all articles tagged with "indispensable", for example:

Article.find_tagged_with("indispensable")

This returns an array of objects associated with that tag. There's no method to find all object types by tag name but there's no reason you couldn't add such a method to the Tag class.

Discussion

To demonstrate how to use this plug-in, create some fixtures, and load them into your database with rake db:fixtures:load:

test/fixtures/articles.yml:

first:
 id: 1
 title: Vim 7.0 Released!
 body: Vim 7 adds native spell checking, tabs and the app...
another:
 id: 2
 title: Foo Camp
 body: The bar at Foo Camp is appropriately named Foo Bar...
third:
 id: 3
 title: Web 4.0 
 body: Time to refactor...

test/fixtures/announcements.yml:

first:
 id: 1
 body: Classes will start in November.
second:
 id: 2
 body: There will be a concert at noon in the quad.

Now, open a Rails console session and instantiate an Article object. Assign a few tags with tag_with, then list them with tag_list. Next, add an additional tag with tag_with. Now, tag_list shows all four tags. This behaviorappending new tags to the listis the result of our modified version of tag_with. The unmodified version removes existing tags whenever you add new ones.

$ ./script/console 
Loading development environment.
>> article = Article.find(1)
=> #<Article:0x25909f4 @attributes={"created_on"=>nil, 
"body"=>"Vim 7 adds native spell checking, tabs and the app...", 
"title"=>"Vim 7.0 Released!", "updated_on"=>nil, "id"=>"1"}>
>> article.tag_with('editor bram uganda') 
=> ["bram", "editor", "uganda"]
>> article.tag_list
=> "bram editor uganda"
>> article.tag_with('productivity')
=> ["bram", "editor", "productivity", "uganda"]
>> article.tag_list
=> "bram editor uganda productivity"

Now create an Announcement object, and assign it a couple of tags:

>> announcement = Announcement.find(1)
=> #<Announcement:0x25054a8 @attributes={"created_on"=>nil, 
"body"=>"Classes will start in November.", "updated_on"=>nil, "id"=>"1"}>
>> announcement.tag_with('important schedule')
=> ["important", "schedule"]
>> announcement.tag_list
=> "important schedule"

The plug-in allows you to assign tags to any number of models as long as they are declared as taggable (as in the solution with acts_as_taggable in the model class definitions). This is due to a polymorphic association with the taggable interface as set up by the following lines of the acts_as_taggable class method in acts_as_taggable.rb:

def acts_as_taggable(options = {})
 write_inheritable_attribute(:acts_as_taggable_options, {
 :taggable_type => ActiveRecord::Base.\
 send(:class_name_of_active_record_descendant, self).to_s,
 :from => options[:from]
 })
 class_inheritable_reader :acts_as_taggable_options
 has_many :taggings, :as => :taggable, :dependent => true
 has_many :tags, :through => :taggings
 include ActiveRecord::Acts::Taggable::InstanceMethods
 extend ActiveRecord::Acts::Taggable::SingletonMethods 
end

...along with the corresponding association method calls in the tagging.rb and tag.rb:

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

class Tag < ActiveRecord::Base
 has_many :taggings
 ...
end

The taggings table stores all the associations between tags and objects being tagged. The taggable_id and taggable_type columns differentiate between the different object type associations. Here is the contents of this table after we've assigned tags to Article and Announcement objects:

mysql> select * from taggings;
+----+--------+-------------+---------------+
| id | tag_id | taggable_id | taggable_type |
+----+--------+-------------+---------------+
| 4 | 1 | 1 | Article | 
| 5 | 2 | 1 | Article | 
| 6 | 4 | 1 | Article | 
| 7 | 3 | 1 | Article | 
| 8 | 5 | 1 | Announcement | 
| 9 | 6 | 1 | Announcement | 
+----+--------+-------------+---------------+

The specific modifications made to the plug-in's default instance methods include fixing what looks to be a typo in tag_list, but also adding the call to self.reload in that method. Calling self.reload allows you to view all current tags on an object with tag_list immediately after adding more tags with tag_with. The other significant addition is to the tag_with method. The method has been altered to save all current tags, then destroy all taggings with taggings.destroy_all, and finally to create a new list of taggings that merges the existing taggings with those being added as parameters. The end result is that tag_with now has a cumulative effect when tags are added.

See Also