The acts_as_taggable
plugin, although built in to Rails, is basically deprecated. And the gem version is old and requires a separate join table for every taggable model, which is silly.
update
Please see the updated has_many_polymorphs documentation, which has instructions for using the new, built-in tagging generator.
install
Uninstall whatever acts_as_taggable
version you have, to prevent fighting among the plugins. Then, install has_many_polymorphs
:
script/plugin install -x svn://rubyforge.org/var/svn/fauna/has_many_polymorphs/trunk
models
We will assume you are using the default acts_as_taggable
models. You should have a Tag model, with a name
field, as well as a Taggings join model. However, these models are part of the acts_as_taggable
plugin source, whereas their migrations have to be part of your app (which is confusing). Instead, with has_many_polymorphs
, we have to make the models part of the app, too:
class Tag < ActiveRecord::Base
has_many_polymorphs :taggables,
:from => [:books, :magazines],
:through => :taggings,
:dependent => :destroy
end
class Tagging < ActiveRecord::Base
belongs_to :tag
belongs_to :taggable, :polymorphic => true
def before_destroy
# disallow orphaned tags
tag.destroy_without_callbacks if tag.taggings.count < 2
end
end
See the line [:books, :magazines]
? This line replaces all the acts_as_taggable
macro calls strewn through your models. Simply list in the array the models that you want to be able to tag. (You can even tag Tags, see below.)
migrations
We need to make sure your existing tag schema fits this. It should be something like:
class AddTagSupport < ActiveRecord::Migration
def self.up
create_table :tags do |t|
t.column :name, :string, :null => false
end
add_index :tags, :name, :unique => true
create_table :taggings do |t|
t.column :tag_id, :integer, :null => false
t.column :taggable_id, :integer, :null => false
t.column :taggable_type, :string, :null => false
end
add_index :taggings, [:tag_id, :taggable_id, :taggable_type], :unique => true
end
def self.down
drop_table :tags
drop_table :taggings
end
end
If your schema isn’t like this already, you have two choices. You can add more options to the association macros in your models, to show them how to relate to your schema, or you can write a migration to convert your schema into canonical form. The first option is possibly quicker to implement, but the second one will be easier to maintain.
api
Hey, you’re done! Well, not really. The API is different, which is the biggest sticking point. We’ll write some convenience methods to mimic the old way. You should put the methods in RAILS_ROOT/lib/tag_extensions.rb
or similar.
class ActiveRecord::Base
def tag_with tags
tags.split(" ").each do |tag|
Tag.find_or_create_by_name(tag).taggables << self
end
end
def tag_list
tags.map(&:name).join(' ')
end
end
Pretty straightforward, which makes it easy to modify. For example, to make creating a model from params
more transparent, we could add:
alias :tags= :tag_with
Or to enforce lowercase tags, just do:
def tag_with tags
tags.downcase.split(" ").each do |tag|
Tag.find_or_create_by_name(tag).taggables << self
end
end
If you want to allow tags with spaces in them, we can accept an array instead of a string:
def tag_with *tags
tags.flatten.each do |tag|
Tag.find_or_create_by_name(tag).taggables << self
end
end
To delete tags (don’t forget to downcase
the tag_string
if you need to):
def tag_delete tag_string
split = tag_string.split(" ")
tags.delete tags.select{|t| split.include? t.name}
end
To get all models for a tag (and all in a single SQL query, thanks to the plugin):
Tag.find_by_name("artichoke").taggables
To get only a specific model for a tag:
Tag.find_by_name("artichoke").books
Easy and powerful. And if you need to find which tags are most popular, see here.
Make sure that you require
the file containing your API methods in environment.rb
, since Rails won’t load it automatically:
require 'tag_extensions'
performance note
It is more efficient if we add :skip_duplicates => false
to the has_many_polymorphs :taggables
call. Then the taggables
for each Tag won’t get loaded at all during the <<
, because there is no reason to check them.
If we do this, though, and use database constraints to enforce uniqueness, we need to manually rescue
assignment errors in our tag_with
method:
def tag_with tags
tags.split(" ").each do |tag|
begin
Tag.find_or_create_by_name(tag).taggables << self
rescue ActiveRecord::StatementInvalid => e
raise unless e.to_s[/Duplicate entry/]
end
end
end
self-referential tagging
What if you want to be able to tag tags? This is more useful than might first appear. It lets you create an ad-hoc non-exclusive hierarchy of categories without extra models. This is much more maintainable than one based on hard-coded models for each level. But you need some way to distinguish “the tags tagged by a tag” and “the tags a tag is tagged by”—a directed graph. So we will rename the parent relationship:
class Tag < ActiveRecord::Base
has_many_polymorphs :taggables,
:from => [:books, :magazines, :tags],
:through => :taggings,
:dependent => :destroy,
:as => :tagger
end
class Tagging < ActiveRecord::Base
belongs_to :tagger,
:class_name => "Tag",
:foreign_key => "tagger_id"
belongs_to :taggable,
:polymorphic => true
end
Modify the migration accordingly. Now, you can use some_tag.taggables
to get the targets of some_tag
, and some_tag.taggers
to get the tags for which some_tag
is itself a target.
Note that your tag_delete
method might have to be more longwinded due to this long-outstanding Rails bug. (Rails 1.2.3 might be ok now; it’s unclear.)
wrapping up
You should find, once you complete the move, that tag lookup is faster, that the API is more intuitive and in line with regular ActiveRecord, that database compatibility is improved, and that the subsystem is more extensible. Quite a benefit!
Since has_many_polymorphs
is basically a huge superset of the acts_as_taggable
features, there is not much that isn’t supported. Many people use has_many_polymorphs
in production for tagging already. Join them!