snax

growing up your acts_as_taggable

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!