module Tagtical::Taggable
module Core
def self.included(base)
base.class_eval do
include Tagtical::Taggable::Core::InstanceMethods
extend Tagtical::Taggable::Core::ClassMethods
after_save :save_tags
initialize_tagtical_core
end
end
module ClassMethods
def initialize_tagtical_core
has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag, :class_name => "Tagtical::Tagging"
has_many :tags, :through => :taggings, :source => :tag, :class_name => "Tagtical::Tag",
:select => "#{Tagtical::Tag.table_name}.*, #{Tagtical::Tagging.table_name}.relevance" # include the relevance on the tags
tag_types.each do |tag_type| # has_many :tags gets created here
# Aryk: Instead of defined multiple associations for the different types of tags, I decided
# to define the main associations (tags and taggings) and use AR scope's to build off of them.
# This keeps your reflections cleaner.
# In the case of the base tag type, it will just use the :tags association defined above.
Tagtical::Tag.define_scope_for_type(tag_type)
# If the tag_type is base? (type=="tag"), then we add additional functionality to the AR
# has_many :tags.
#
# taggable_model.tags(:type => :children)
# taggable_model.tags <-- still works like normal has_many
# taggable_model.tags(true, :type => :current) <-- reloads the tags association and appends scope for only current type.
if tag_type.has_many_name==:tags
define_method("tags_with_finder_type_options") do |*args|
options = args.pop if args.last.is_a?(Hash)
scope = tags_without_finder_type_options(*args)
options ? scope.tags(options) : scope
end
alias_method_chain :tags, :finder_type_options
else
delegate tag_type.has_many_name, :to => :tags
end
class_eval <<-RUBY, __FILE__, __LINE__ + 1
def self.with_#{tag_type.pluralize}(*tags)
options = tags.extract_options!
tagged_with(tags.flatten, options.merge(:on => :#{tag_type}))
end
def #{tag_type}_list
tag_list_on('#{tag_type}')
end
def #{tag_type}_list=(new_tags)
set_tag_list_on('#{tag_type}', new_tags)
end
def all_#{tag_type.pluralize}_list
all_tags_list_on('#{tag_type}')
end
RUBY
end
end
def acts_as_taggable(*args)
super(*args)
initialize_tagtical_core
end
# all column names are necessary for PostgreSQL group clause
def grouped_column_names_for(object)
object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
end
##
# Return a scope of objects that are tagged with the specified tags.
#
# @param tags The tags that we want to query for
# @param [Hash] options A hash of options to alter you query:
# * :exclude - if set to true, return objects that are *NOT* tagged with the specified tags
# * :any - if set to true, return objects that are tagged with *ANY* of the specified tags
# * :match_all - if set to true, return objects that are *ONLY* tagged with the specified tags
#
# Example:
# User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
# User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
# User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
# User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
# User.tagged_with("awesome", "cool", :on => :skills) # Users that are tagged with just awesome and cool on skills
def tagged_with(tags, options = {})
tag_list = Tagtical::TagList.from(tags)
return scoped(:conditions => "1 = 0") if tag_list.empty? && !options[:exclude]
joins = []
conditions = []
options[:on] ||= Tagtical::Tag::Type::BASE
tag_type = Tagtical::Tag::Type.find(options.delete(:on))
finder_type_condition_options = options.extract!(:type)
tag_table, tagging_table = Tagtical::Tag.table_name, Tagtical::Tagging.table_name
if options.delete(:exclude)
conditions << "#{table_name}.#{primary_key} NOT IN (" +
"SELECT #{tagging_table}.taggable_id " +
"FROM #{tagging_table} " +
"JOIN #{tag_table} ON #{tagging_table}.tag_id = #{tag_table}.id AND #{tag_list.to_sql_conditions(:operator => "LIKE")} " +
"WHERE #{tagging_table}.taggable_type = #{quote_value(base_class.name)})"
elsif options.delete(:any)
conditions << tag_list.to_sql_conditions(:operator => "LIKE")
tagging_join = " JOIN #{tagging_table}" +
" ON #{tagging_table}.taggable_id = #{table_name}.#{primary_key}" +
" AND #{tagging_table}.taggable_type = #{quote_value(base_class.name)}" +
" JOIN #{tag_table}" +
" ON #{tagging_table}.tag_id = #{tag_table}.id"
if (finder_condition = tag_type.finder_type_condition(finder_type_condition_options.merge(:sql => true))).present?
conditions << finder_condition
end
select_clause = "DISTINCT #{table_name}.*" unless !tag_type.base? and tag_types.one?
joins << tagging_join
else
tags_by_value = tag_type.scoping(finder_type_condition_options).where_any_like(tag_list).group_by(&:value)
return scoped(:conditions => "1 = 0") unless tags_by_value.length == tag_list.length # allow for chaining
# Create only one join per tag value.
tags_by_value.each do |value, tags|
tags.each do |tag|
safe_tag = value.gsub(/[^a-zA-Z0-9]/, '')
prefix = "#{safe_tag}_#{rand(1024)}"
taggings_alias = "#{undecorated_table_name}_taggings_#{prefix}"
tagging_join = "JOIN #{tagging_table} #{taggings_alias}" +
" ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
" AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
" AND #{sanitize_sql("#{taggings_alias}.tag_id" => tags.map(&:id))}"
joins << tagging_join
end
end
end
taggings_alias, tags_alias = "#{undecorated_table_name}_taggings_group", "#{undecorated_table_name}_tags_group"
if options.delete(:match_all)
joins << "LEFT OUTER JOIN #{tagging_table} #{taggings_alias}" +
" ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
" AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
group_columns = Tagtical::Tag.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
group = "#{group_columns} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tag_list.size}"
end
scoped(:select => select_clause,
:joins => joins.join(" "),
:group => group,
:conditions => conditions.join(" AND "),
:order => options[:order],
:readonly => false)
end
def is_taggable?
true
end
end
module InstanceMethods
# all column names are necessary for PostgreSQL group clause
def grouped_column_names_for(object)
self.class.grouped_column_names_for(object)
end
def is_taggable?
self.class.is_taggable?
end
def cached_tag_list_on(context)
self[tag_type(context).tag_list_name(:cached)]
end
def tag_list_cache_set_on?(context)
variable_name = tag_type(context).tag_list_ivar
!instance_variable_get(variable_name).nil?
end
def tag_list_cache_on(context)
variable_name = tag_type(context).tag_list_ivar
instance_variable_get(variable_name) || instance_variable_set(variable_name, Tagtical::TagList.new(tags_on(context).map(&:value)))
end
def tag_list_on(context)
tag_list_cache_on(context)
end
def tag_types
@tag_types ||= self.class.tag_types.dup
end
def all_tags_list_on(context)
variable_name = tag_type(context).tag_list_ivar(:all)
return instance_variable_get(variable_name) if instance_variable_get(variable_name)
instance_variable_set(variable_name, Tagtical::TagList.new(all_tags_on(context).map(&:value)).freeze)
end
##
# Returns all tags of a given context
def all_tags_on(context, options={})
scope = tag_scope(context, options)
if Tagtical::Tag.using_postgresql?
group_columns = grouped_column_names_for(Tagtical::Tag)
scope = scope.order("max(#{Tagtical::Tagging.table_name}.created_at)").group(group_columns)
else
scope = scope.group("#{Tagtical::Tag.table_name}.#{Tagtical::Tag.primary_key}")
end
scope.all
end
##
# Returns all tags that aren't owned.
def tags_on(context, options={})
tag_scope(context, options).where("#{Tagtical::Tagging.table_name}.tagger_id IS NULL").all
end
def set_tag_list_on(context, new_list)
variable_name = tag_type(context).tag_list_ivar
instance_variable_set(variable_name, Tagtical::TagList.from(new_list))
end
def reload(*args)
tag_types.each do |tag_type|
instance_variable_set(tag_type.tag_list_ivar, nil)
instance_variable_set(tag_type.tag_list_ivar(:all), nil)
end
super(*args)
end
def save_tags
# Do the classes from top to bottom. We want the list from "tag" to run before "sub_tag" runs.
# Otherwise, we will end up removing taggings from "sub_tag" since they aren't on "tag'.
tag_types.sort_by(&:active_record_sti_level).each do |tag_type|
next unless tag_list_cache_set_on?(tag_type)
tag_list = tag_list_cache_on(tag_type).uniq
# Find existing tags or create non-existing tags:
tag_value_lookup = tag_type.scoping { find_or_create_tags(tag_list) }
tags = tag_value_lookup.keys
current_tags = tags_on(tag_type, :type => [:current, :parents, :children])
old_tags = current_tags - tags
new_tags = tags - current_tags
unowned_taggings = taggings.where(:tagger_id => nil)
# If relevances are specified on current tags, make sure to update those
tags_requiring_relevance_update = tag_value_lookup.map { |tag, value| tag if !value.relevance.nil? }.compact & current_tags
if tags_requiring_relevance_update.present? && (update_taggings = unowned_taggings.find_all_by_tag_id(tags_requiring_relevance_update)).present?
update_taggings.each { |tagging| tagging.update_attribute(:relevance, tag_value_lookup[tagging.tag].relevance) }
end
# Find and remove old taggings:
if old_tags.present? && (old_taggings = unowned_taggings.find_all_by_tag_id(old_tags)).present?
old_taggings.reject! do |tagging|
if tagging.tag.class > tag_type.klass! # parent of current tag type/class, make sure not to remove these taggings.
update_tagging_with_inherited_tag!(tagging, new_tags, tag_value_lookup)
true
end
end
Tagtical::Tagging.destroy_all :id => old_taggings.map(&:id) # Destroy old taggings:
end
new_tags.each do |tag|
taggings.create!(:tag_id => tag.id, :taggable => self, :relevance => tag_value_lookup[tag].relevance) # Create new taggings:
end
end
true
end
private
def tag_scope(input, options={})
tags.where(tag_type(input).finder_type_condition(options))
end
# Returns the tag type for the given context and adds any new types tag_types array on this instance.
def tag_type(input)
(@tag_type ||= {})[input] ||= Tagtical::Tag::Type[input].tap do |tag_type|
tag_types << tag_type unless tag_types.include?(tag_type)
end
end
# Lets say tag class A inherits from B and B has a tag with value "foo". If we tag A with value "foo",
# we want B to have only one instance of "foo" and that tag should be an instance of A (a subclass of B).
def update_tagging_with_inherited_tag!(tagging, tags, tag_value_lookup)
if tags.present? && (tag = Tagtical::Tag.send(:detect_comparable, tags, tagging.tag.value))
tagging.update_attributes!(:tag => tag, :relevance => tag_value_lookup[tag].relevance)
tags.delete(tag)
end
end
end
end
end