require 'active_record' module ActiveRecord module Acts module Taggable extend ActiveSupport::Concern module ClassMethods def acts_as_taggable has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag has_many :tags, :through => :taggings before_save :save_cached_tag_list after_save :save_tags extend ActiveRecord::Acts::Taggable::SingletonMethods alias_method_chain :reload, :tag_list end def cached_tag_list_column_name :cached_tag_list end def set_cached_tag_list_column_name(value = nil, &block) define_attr_method :cached_tag_list_column_name, value, &block end end module SingletonMethods # Returns an array of related tags. # Related tags are all the other tags that are found on the models tagged with the provided tags. # # Pass either a tag, string, or an array of strings or tags. # # Options: # :order - SQL Order how to order the tags. Defaults to "count DESC, tags.name". def find_related_tags(tags, options = {}) tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags) related_models = find_tagged_with(tags) return [] unless related_models.exists? related_ids = related_models.select("distinct #{table_name}.id") Tag.select("#{Tag.table_name}.*, COUNT(#{Tag.table_name}.id) AS count"). joins("JOIN #{Tagging.table_name} ON #{Tagging.table_name}.taggable_type = '#{base_class.name}' AND #{Tagging.table_name}.taggable_id IN (#{related_ids.to_sql}) AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id"). order(options[:order] || "count DESC, #{Tag.table_name}.name"). group("#{Tag.table_name}.id, #{Tag.table_name}.name HAVING LOWER(#{Tag.table_name}.name) NOT IN (#{tags.map { |n| quote_value(n.downcase) }.join(",")})") end # Pass either a tag, string, or an array of strings or tags. # # Options: # :exclude - Find models that are not tagged with the given tags # :match_all - Find models that match all of the given tags, not just one # :conditions - A piece of SQL conditions to add to the query # :exclude_subtags - Find models that are tagged with only given tags, not their subtags def find_tagged_with(tags, options = {}) tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags) options = options.dup exclude_subtags = options.delete(:exclude_subtags) return where("1=0") if tags.empty? taggings_alias = "#{table_name}_taggings" tags_alias = "#{table_name}_tags" results = base_class.joins("INNER 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)} " + "INNER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id") if options.delete(:exclude) tc = tags_condition(tags, Tag.table_name, !exclude_subtags) results = results.where("#{table_name}.id NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id WHERE #{tc} AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})") else if options.delete(:match_all) tc = tags_condition(tags, Tag.table_name, !exclude_subtags) results = results.where(" (SELECT COUNT(*) FROM #{Tagging.table_name} INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)} AND taggable_id = #{table_name}.id AND #{tc}) = #{tags.size}") else results = results.where(tags_condition(tags, tags_alias, !exclude_subtags)) end end results end # Calculate the tag counts for all tags. # # See Tag.counts for available options. def tag_counts(options = {}) Tag.find(:all, find_options_for_tag_counts(options)) end def find_options_for_tag_counts(options = {}) options = options.dup scope = scoped conditions = [] conditions << send(:sanitize_conditions, options.delete(:conditions)) if options[:conditions] conditions << scope.where_values.reduce(:and).to_sql if scope.where_values.any? conditions << "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}" conditions << type_condition.to_sql unless descends_from_active_record? conditions.compact! conditions = conditions.join(" AND ") joins = ["INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"] joins << options.delete(:joins) if options[:joins] joins << scope.joins_values.map(&:to_sql).join if scope.joins_values.any? joins = joins.join(" ") options = { :conditions => conditions, :joins => joins }.update(options) Tag.options_for_counts(options) end def caching_tag_list? column_names.include?(cached_tag_list_column_name.to_s) end private def tags_condition(tags, table_name = Tag.table_name, include_subtags = true) # FIXME N+1 tags += tags.map do |tag_name| tag = Tag.find_with_like_by_name(tag_name) tag ? tag.transitive_children.find(:all).map(&:name) : [] end.flatten if include_subtags condition = tags.map { |t| sanitize_sql(["#{table_name}.name LIKE ?", t]) }.join(" OR ") condition.blank? ? '(1=0)' : "(" + condition + ")" end end included do def tag_list return @tag_list if @tag_list if self.class.caching_tag_list? and !(cached_value = send(self.class.cached_tag_list_column_name)).nil? @tag_list = TagList.from(cached_value) else @tag_list = TagList.new(*tags.map(&:name)) end end def tag_list=(value) @tag_list = TagList.from(value) end def save_cached_tag_list if self.class.caching_tag_list? self[self.class.cached_tag_list_column_name] = tag_list.to_s end end def save_tags return unless @tag_list new_tag_names = @tag_list - tags.map(&:name) old_tags = tags.reject { |tag| @tag_list.include?(tag.name) } self.class.transaction do if old_tags.any? taggings.find(:all, :conditions => ["tag_id IN (?)", old_tags.map(&:id)]).each(&:destroy) taggings.reset end new_tag_names.each do |new_tag_name| tags << Tag.find_or_create_with_like_by_name(new_tag_name) end end true end # Calculate the tag counts for the tags used by this model. # # The possible options are the same as the tag_counts class method, excluding :conditions. def tag_counts(options = {}) self.class.tag_counts({ :conditions => self.class.send(:tags_condition, tag_list, Tag.table_name, false) }.reverse_merge!(options)) end def reload_with_tag_list(*args) #:nodoc: @tag_list = nil reload_without_tag_list(*args) end end end end end ActiveRecord::Base.send(:include, ActiveRecord::Acts::Taggable)