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)
define_tag_scope(tag_type)
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(*args)
tag_list_on('#{tag_type}', *args)
end
def #{tag_type}_list=(new_tags, *args)
set_tag_list_on('#{tag_type}', new_tags, *args)
end
alias set_#{tag_type}_list #{tag_type}_list=
def all_#{tag_type.pluralize}_list(*args)
all_tags_list_on('#{tag_type}', *args)
end
RUBY
end
end
def acts_as_taggable(*args)
super(*args)
initialize_tagtical_core
end
# If the tag_type is base? (type=="tag"), then we add additional functionality to the AR
# has_many :tags.
#
# taggable_model.tags(:scope => :children)
# taggable_model.tags <-- still works like normal has_many
# taggable_model.tags(true, :scope => :current) <-- reloads the tags association and appends scope for only current type.
def define_tag_scope(tag_type)
if tag_type.has_many_name==:tags
define_method("tags_with_finder_type_options") do |*args|
bool = args.shift if [true, false].include?(args.first)
tags = tags_without_finder_type_options(bool)
args.empty? ? tags : tags.scoped.merge(tag_type.scoping(*args))
end
alias_method_chain :tags, :finder_type_options
else
define_method(tag_type.has_many_name) do |*args|
tags.scoped.merge(tag_type.scoping(*args))
end
end
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
def find_tag_type!(input, options={})
(@tag_type ||= {})[input] ||= tag_types.find { |t| t.match?(input) } || raise("Cannot find tag type:'#{input}' in #{tag_types.inspect}")
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 = []
tag_type = find_tag_type!(options.delete(:on) || Tagtical::Tag::Type::BASE)
finder_type_condition_options = options.extract!(:scope)
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}.*" if tag_type.klass.descends_from_active_record? || !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[find_tag_type!(context).tag_list_name(:cached)]
end
##
# model.set_tag_list_on("skill", ["kung fu", "karate"]) # will overwrite tags from inheriting tag classes
# model.set_tag_list_on("skill", ["kung fu", "karate"], :scope => :==) # will not overwrite tags from inheriting tag classes
def set_tag_list_on(context, new_list, *args)
tag_list = Tagtical::TagList.from(new_list)
cascade_set_tag_list!(tag_list, context, *args) if args[-1].is_a?(Hash) && args[-1].delete(:cascade)
tag_list_cache_on(context)[expand_tag_types(context, *args)] = tag_list
end
def tag_list_on?(context, *args)
!tag_list_cache_on(context)[expand_tag_types(context, *args)].nil?
end
def tag_list_on(context, *args)
tag_list_cache_on(context)[expand_tag_types(context, *args)] ||= Tagtical::TagList.new(tags_on(context, *args))
end
def tag_list_cache_on(context, prefix=nil)
variable = find_tag_type!(context).tag_list_ivar(prefix)
instance_variable_get(variable) || instance_variable_set(variable, {})
end
def all_tags_list_on(context, *args)
tag_list_cache_on(context, :all)[expand_tag_types(context, *args)] ||= Tagtical::TagList.new(all_tags_on(context, *args)).freeze
end
##
# Returns all tags of a given context
def all_tags_on(context, *args)
scope = tag_scope(context, *args)
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, *args)
tag_scope(context, *args).where("#{Tagtical::Tagging.table_name}.tagger_id IS NULL").all
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|
(tag_list_cache_on(tag_type) || {}).each do |expanded_tag_types, tag_list|
# Tag list saving only runs if its affecting the current scope or the current and children scope
# next unless [:<=, :==].any? { |scope| scopes_for_tag_list(tag_type, scope)==scopes }
next unless expanded_tag_types.include?(tag_type)
tag_list = tag_list.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, :types => expanded_tag_types, :scope => :parents) # add in the parents because we need them later on down.
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
# Force tag lists to reload to integrate any new tags from inheritance.
remove_tag_caches_on(tag_type)
end
true
end
private
def remove_tag_caches_on(tag_type)
[nil, :all].each do |prefix|
ivar = tag_type.tag_list_ivar(prefix)
remove_instance_variable(ivar) if instance_variable_defined?(ivar)
end
end
def tag_scope(input, *args)
tags.where(find_tag_type!(input).finder_type_condition(*args))
end
def find_tag_type!(input)
self.class.find_tag_type!(input)
end
def finder_type_arguments_for_tag_list(input, *args)
find_tag_type!(input).send(:convert_finder_type_arguments, *args)
end
def expand_tag_types(input, *args)
(@expand_tag_types ||= {})[[input, args]] ||= begin
scopes, options = finder_type_arguments_for_tag_list(input, *args)
find_tag_type!(input).send(:expand_tag_types, scopes, options)
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
# Extracts the valid tag types for the cascade option.
def extract_tag_types_from_cascade(input, base_tag_type)
case input
when Hash
if except = input[:except]
except = extract_tag_types_from_cascade(except, base_tag_type)
tag_types.reject { |t| except.any? { |e| t.klass <= e.klass }} # remove children as well.
elsif only = input[:only]
extract_tag_types_from_cascade(only, base_tag_type)
else raise("Please provide :except or :only")
end
when true
tag_types
else
Array(input).map { |c| find_tag_type!(c) }
end.select do |tag_type|
tag_type.klass <= base_tag_type.klass && tag_type.klass.possible_values
end
end
# If cascade tag types are specified, it will attempt to look at Tag subclasses with
# possible_values and try to set those tag_lists with values from the possible_values list.
def cascade_set_tag_list!(tag_list, context, *args)
expand_tag_types(context, *args).each do |tag_type|
if tag_type.klass.possible_values
new_tag_list = Tagtical::TagList.new
tag_list.reject! do |tag_value|
if value = tag_type.klass.detect_possible_value(tag_value)
new_tag_list << Tagtical::TagList::TagValue.new(value, tag_value.relevance)
true
end
end
set_tag_list_on(tag_type, new_tag_list, :current) if !new_tag_list.empty?
end
end
end
end
end
end