One of the features which sets acts_as_taggable_on apart from other Rails tagging plugins is the acts_as_tagger mechanic of tag ownership, whereby a “tagger” model tracks its tagging of other models. This design unlocks a lot of power in managing dynamic, abstract relationships.
Unfortunately, it happens to be one of the less mature areas of the code and there isn’t much in the way of convenience methods to help us traverse the tagger relationship. Finally, I uncovered an insidious bug in the tagging conditions that will reject valid owner tagging, and which must be fixed if you wish to use the acts_as_tagger parts of the plugin.
In order to extend acts_as_taggable_on, we’ll need to add a Ruby file defining our extensions to the plugin classes. We’ll also use this file to patch the tag condition bug by overriding the problematic method, save_tags. Because we should be able to continue updating the plugin without overwriting our changes, we’ll keep this in the Rails catchall code directory, /lib, and initialize it from our config/initializers directory.
Let’s start by adding two additional files to the project:
- /config/initializers/plugin_extensions.rb
- /lib/plugin_extensions/acts_as_taggable_on.rb
The plugin_extensions.rb file is quite simple, serving only to apply our changes when the app loads.
plugin_extensions.rb
require 'plugin_extensions/acts_as_taggable_on'
To the acts_as_taggable and acts_as_tagger modules, we’ll be adding two finder methods: find_taggers_for and find_tagged_by. These will return, respectively, a collection of taggers who have tagged a given taggable object, and a collection of taggable objects tagged by a given tagger. Nothing elaborate, just a couple of methods to improve the acts_as_tagger implementation. Here’s what the skeleton code looks like.
acts_as_taggable_on.rb
module ActsAsTaggableOnFindTaggersExtension
. . .
end
module ActsAsTaggableOnFindTaggedByExtension
. . .
end
module ActiveRecord
module Acts
module Tagger
module SingletonMethods
include ActsAsTaggableOnFindTaggersExtension
end
end
end
end
module ActiveRecord
module Acts
module TaggableOn
module InstanceMethods
def save_tags
. . .
end
end
module SingletonMethods
include ActsAsTaggableOnFindTaggedByExtension
end
end
end
end
This code tells the original acts_as_taggable_on to include our extension modules, as well implementing an override of the save_tags InstanceMethod in order to fix the tag condition bug I’ve mentioned. Let’s fix that first. Here’s the original save method:
def save_tags
(custom_contexts + self.class.tag_types.map(&:to_s)).each do |tag_type|
next unless instance_variable_get("@#{tag_type.singularize}_list")
owner = instance_variable_get("@#{tag_type.singularize}_list").owner
new_tag_names = instance_variable_get("@#{tag_type.singularize}_list") - tags_on(tag_type).map(&:name)
old_tags = tags_on(tag_type, owner).reject { |tag| instance_variable_get("@#{tag_type.singularize}_list").include?(tag.name) }
self.class.transaction do
base_tags.delete(*old_tags) if old_tags.any?
new_tag_names.each do |new_tag_name|
new_tag = Tag.find_or_create_with_like_by_name(new_tag_name)
Tagging.create(:tag_id => new_tag.id, :context => tag_type,
:taggable => self, :tagger => owner)
end
end
end
The problem occurs on this line:
new_tag_names = instance_variable_get("@#{tag_type.singularize}_list") - tags_on(tag_type).map(&:name)
When differencing the old tag names with the tags_on method, the code will reject any matching tags in order to prevent duplicate tagging. However, duplicate taggings are perfectly valid when tracking taggers; as written, only the first tagger to tag a model will have their tag saved. Thankfully, the solution is simple: the tags_on methods takes another optional parameter, owner. If a tagger model is provided, tags_on will correctly filter the tag collection.
All we must do, then, is supply the tagger object here, and the code will difference the tags as expected. We’ll finish filling out this portion of our extension file with the overridden save_tags method containing the fix.
module ActiveRecord
module Acts
module TaggableOn
module InstanceMethods
def save_tags
(custom_contexts + self.class.tag_types.map(&:to_s)).each do |tag_type|
next unless instance_variable_get("@#{tag_type.singularize}_list")
owner = instance_variable_get("@#{tag_type.singularize}_list").owner
new_tag_names = instance_variable_get("@#{tag_type.singularize}_list") - tags_on(tag_type, owner).map(&:name)
old_tags = tags_on(tag_type, owner).reject { |tag| instance_variable_get("@#{tag_type.singularize}_list").include?(tag.name) }
self.class.transaction do
base_tags.delete(*old_tags) if old_tags.any?
new_tag_names.each do |new_tag_name|
new_tag = Tag.find_or_create_with_like_by_name(new_tag_name)
Tagging.create(:tag_id => new_tag.id, :context => tag_type,
:taggable => self, :tagger => owner)
end
end
end
true
end
end
module SingletonMethods
include ActsAsTaggableOnFindTaggedByExtension
end
end
end
end
The two custom finder methods for tagger models will mimic the existing find_tagged_with and find_options_for_find_tagged_with methods. Behind the finder methods, the real work is done by options methods which compose the necessary raw SQL, ActiveRecord conditions, and so forth. This is where any other filtering options would be processed if you wanted to further customize the tag retrieval logic.
module ActsAsTaggableOnFindTaggersExtension
def find_taggers_for(*args)
options = find_options_for_find_taggers_for(*args)
options.blank? ? [] : find(:all,options)
end
def find_options_for_find_taggers_for(tags, taggable, options = {})
tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
return {} if tags.empty?
conditions = []
conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
unless (on = options.delete(:on)).nil?
conditions << sanitize_sql(["context = ?",on.to_s])
end
taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
conditions << tags.map { |t| sanitize_sql(["#{tags_alias}.name LIKE ?", t]) }.join(" OR ")
conditions << sanitize_sql(["#{taggings_alias}.taggable_id = #{taggable.id} AND #{taggings_alias}.taggable_type = '#{taggable.class.to_s}'"])
{ :select => "DISTINCT #{table_name}.*",
:joins => "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.tagger_id = #{table_name}.#{primary_key} AND #{taggings_alias}.tagger_type = #{quote_value(base_class.name)} " +
"LEFT OUTER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id",
:conditions => conditions.join(" AND ")
}.update(options)
end
end
module ActsAsTaggableOnFindTaggedByExtension
def find_tagged_by(*args)
options = find_options_for_find_tagged_by(*args)
options.blank? ? [] : find(:all,options)
end
def find_options_for_find_tagged_by(tags, tagger, options = {})
tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
return {} if tags.empty?
conditions = []
conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
unless (on = options.delete(:on)).nil?
conditions << sanitize_sql(["context = ?",on.to_s])
end
taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
conditions << tags.map { |t| sanitize_sql(["#{tags_alias}.name LIKE ?", t]) }.join(" OR ")
conditions << sanitize_sql(["#{taggings_alias}.tagger_id = #{tagger.id} AND #{taggings_alias}.tagger_type = '#{tagger.class.to_s}'"])
{ :select => "DISTINCT #{table_name}.*",
:joins => "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)} " +
"LEFT OUTER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id",
:conditions => conditions.join(" AND ")
}.update(options)
end
end
Once we’ve defined these methods and included the modules in acts_as_taggable_on, we can invoke them to easily return a list of taggers, or a tagger’s tagged models.
@some_user.tag(@some_photo, :with => "paris, normandy, europe", :on => :locations)
@another_user.tag(@some_photo, :with => "europe", :on => :locations)
@another_user.tag(@another_photo, :with => "europe", :on => :locations)
Photo.find_taggers_for(' europe', @some_photo, :on => :locations)
User. find_tagged_by(' europe', @another_user, :on => :locations)
