#--
# Copyright (c) 2005 Robert Aman
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#++
TAG_TOOLS_VERSION = "0.0.3"
$:.unshift(File.dirname(__FILE__))
$:.unshift(File.dirname(__FILE__) + "/../../activerecord/lib")
require 'rubygems'
require 'active_record'
module ActiveRecord #:nodoc:
module Associations #:nodoc:
class GlobalTagsAssociation < AssociationCollection #:nodoc:
def initialize(owner, tag_class, item_class, options)
@owner = owner
@tag_class = tag_class
@tag_class_name = tag_class.name
@tag_foreign_key = options[:tag_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(@tag_class.name)) + "_id"
@item_class = item_class
@item_class_name = item_class.name
@item_foreign_key = options[:item_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(@item_class.name)) + "_id"
@join_table = options[:join_table]
@options = options
# For the sake of the classes we inheritted from,
# since we're not calling super
@association_class = @tag_class
@association_name = Inflector.pluralize(
Inflector.underscore(Inflector.demodulize(@tag_class.name)))
@association_class_primary_key_name = @item_foreign_key
reset
construct_sql
end
def build(attributes = {})
load_target
record = @item_class.new(attributes)
record[@item_foreign_key] = @owner.id unless @owner.new_record?
@target << record
return record
end
# Removes all records from this association.
# Returns +self+ so method calls may be chained.
def clear
# forces load_target if hasn't happened already
return self if size == 0
@owner.connection.execute("DELETE FROM #{@join_table} " +
"WHERE #{@join_table}.#{@item_foreign_key} = " +
" '#{@owner.id}'")
@target = []
self
end
def find_first
load_target.first
end
def find(*args)
# Return an Array if multiple ids are given.
expects_array = args.first.kind_of?(Array)
ids = args.flatten.compact.uniq
# If no block is given, raise RecordNotFound.
if ids.empty?
raise RecordNotFound, "Couldn't find #{@tag_class.name} without an ID"
else
if ids.size == 1
id = ids.first
record = load_target.detect { |record| id == record.id }
expects_array? ? [record] : record
else
load_target.select { |record| ids.include?(record.id) }
end
end
end
def push_with_attributes(record, join_attributes = {})
unless record.kind_of? @tag_class
record = record.to_s.create_tag
end
join_attributes.each { |key, value| record[key.to_s] = value }
callback(:before_add, record)
insert_record(record, join_attributes) unless @owner.new_record?
@target << record
callback(:after_add, record)
@target.sort!
return self
end
alias :concat_with_attributes :push_with_attributes
def include?(raw_tag)
actual_tag = get_tag(raw_tag)
return false if actual_tag.nil?
return @target.include?(actual_tag)
end
def size
count_records
end
def <<(*records)
result = true
load_target
@owner.transaction do
flatten_deeper(records).each do |record|
record = create_tag(record)
next if self.include?(record)
callback(:before_add, record)
result &&= insert_record(record) unless @owner.new_record?
@target << record
callback(:after_add, record)
end
end
@target.sort!
result and self
end
alias_method :push, :<<
alias_method :concat, :<<
# Remove +records+ from this association. Does not destroy +records+.
def delete(*records)
records = flatten_deeper(records)
for index in 0..records.size
records[index] = create_tag(records[index])
end
records.reject! do |record|
if record.nil?
true
elsif record.new_record?
@target.delete(record)
else
false
end
end
return if records.empty?
@owner.transaction do
records.each { |record| callback(:before_remove, record) }
delete_records(records)
records.each do |record|
@target.delete(record)
callback(:after_remove, record)
end
end
end
protected
def create_tag(raw_tag)
return nil if raw_tag.nil?
if raw_tag.kind_of? @tag_class
return raw_tag
end
tag_object = @tag_class.find_by_name(raw_tag.to_s)
if tag_object.nil?
tag_object = @tag_class.new
tag_object.name = raw_tag.to_s
tag_object.save
end
return tag_object
end
def get_tag(raw_tag)
return nil if raw_tag.nil?
if raw_tag.kind_of? @tag_class
return raw_tag
end
tag_object = @tag_class.find_by_name(raw_tag.to_s)
return tag_object
end
def find_target(sql = @finder_sql)
records = @tag_class.find_by_sql(sql)
uniq(records)
end
def count_records
load_target.size
end
def insert_record(record, join_attributes = {})
record = create_tag(record)
if record.new_record?
return false unless record.save
end
columns = @owner.connection.columns(@join_table, "#{@join_table} Columns")
attributes = columns.inject({}) do |attributes, column|
case column.name
when @item_foreign_key
attributes[column.name] = @owner.quoted_id
when @tag_foreign_key
attributes[column.name] = record.quoted_id
else
value = record[column.name]
attributes[column.name] = value unless value.nil?
end
attributes
end
columns_list = @owner.send(:quoted_column_names,
attributes).join(', ')
values_list = attributes.values.collect { |value|
@owner.send(:quote, value)
}.join(', ')
sql =
"INSERT INTO #{@join_table} (#{columns_list}) " +
"VALUES (#{values_list})"
@owner.connection.execute(sql)
return true
end
def delete_records(records)
ids = quoted_record_ids(records)
sql = "DELETE FROM #{@join_table} " +
"WHERE #{@item_foreign_key} = " +
" #{@owner.quoted_id} " +
"AND #{@tag_foreign_key} IN (#{ids})"
@owner.connection.execute(sql)
end
def construct_sql
if @user_id.nil?
@finder_sql =
"SELECT t.*, j.* FROM #{@join_table} j, " +
" #{@tag_class.table_name} t " +
"WHERE t.#{@tag_class.primary_key} = " +
" j.#{@tag_foreign_key} " +
"AND j.#{@item_foreign_key} = " +
" #{@owner.quoted_id} " +
"ORDER BY t.name"
else
@finder_sql =
"SELECT t.*, j.* FROM #{@join_table} j, " +
" #{@tag_class.table_name} t " +
"WHERE t.#{@tag_class.primary_key} = " +
" j.#{@tag_foreign_key} " +
"AND j.#{@item_foreign_key} = " +
" #{@owner.quoted_id} " +
"AND j.#{@user_foreign_key} = " +
" '#{@user_id}' " +
"ORDER BY t.name"
end
end
end
class UserTagsAssociation < GlobalTagsAssociation #:nodoc:
def initialize(owner, user_id, tag_class, user_class, item_class,
options)
@owner = owner
@user_id = user_id
@tag_class = tag_class
@tag_class_name = tag_class.name
@tag_foreign_key = options[:tag_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(@tag_class.name)) + "_id"
@user_class = user_class
@user_class_name = user_class.name
@user_foreign_key = options[:user_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(@user_class.name)) + "_id"
@item_class = item_class
@item_class_name = item_class.name
@item_foreign_key = options[:item_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(@item_class.name)) + "_id"
@join_table = options[:join_table]
@options = options
# For the sake of the classes we inheritted from,
# since we're not calling super
@association_class = @tag_class
@association_name = Inflector.pluralize(
Inflector.underscore(Inflector.demodulize(@tag_class.name)))
@association_class_primary_key_name = @item_foreign_key
reset
construct_sql
end
def build(attributes = {})
if @user_id.nil?
raise "Can only build object if a tagging user has been specified."
else
load_target
record = @item_class.new(attributes)
record[@item_foreign_key] = @owner.id unless @owner.new_record?
@target << record
return record
end
end
# Removes all records from this association.
# Returns +self+ so method calls may be chained.
def clear
# forces load_target if hasn't happened already
return self if size == 0
if @user_id.nil?
raise "Tags on an item can only be cleared for one user at a time."
else
@owner.connection.execute("DELETE FROM #{@join_table} " +
"WHERE #{@join_table}.#{@item_foreign_key} = " +
" '#{@owner.id}' AND #{@join_table}.#{@user_foreign_key} = " +
" '#{@user_id}'")
end
@target = []
self
end
def find_first
load_target.first
end
def find(*args)
# Return an Array if multiple ids are given.
expects_array = args.first.kind_of?(Array)
ids = args.flatten.compact.uniq
# If no block is given, raise RecordNotFound.
if ids.empty?
raise RecordNotFound, "Couldn't find #{@tag_class.name} without an ID"
else
if ids.size == 1
id = ids.first
record = load_target.detect { |record| id == record.id }
expects_array? ? [record] : record
else
load_target.select { |record| ids.include?(record.id) }
end
end
end
def push_with_attributes(record, join_attributes = {})
if @user_id.nil?
raise "Cannot add record without a specific user id."
else
unless record.kind_of? @tag_class
record = record.to_s.create_tag
end
join_attributes.each { |key, value| record[key.to_s] = value }
callback(:before_add, record)
insert_record(record, join_attributes) unless @owner.new_record?
@target << record
@owner.instance_variable_set("@#{@options[:collection]}", nil)
callback(:after_add, record)
@target.sort!
return self
end
end
alias :concat_with_attributes :push_with_attributes
def <<(*records)
result = true
load_target
@owner.transaction do
flatten_deeper(records).each do |record|
record = create_tag(record)
next if self.include?(record)
callback(:before_add, record)
result &&= insert_record(record) unless @owner.new_record?
@target << record
@owner.instance_variable_set("@#{@options[:collection]}", nil)
callback(:after_add, record)
end
end
@target.sort!
result and self
end
alias_method :push, :<<
alias_method :concat, :<<
protected
def insert_record(record, join_attributes = {})
if @user_id.nil?
raise "Cannot insert record if the user id is not set."
end
record = create_tag(record)
if record.new_record?
return false unless record.save
end
columns = @owner.connection.columns(@join_table, "#{@join_table} Columns")
attributes = columns.inject({}) do |attributes, column|
case column.name
when @item_foreign_key
attributes[column.name] = @owner.quoted_id
when @tag_foreign_key
attributes[column.name] = record.quoted_id
when @user_foreign_key
attributes[column.name] = @user_id.to_s
else
value = record[column.name]
attributes[column.name] = value unless value.nil?
end
attributes
end
columns_list = @owner.send(:quoted_column_names,
attributes).join(', ')
values_list = attributes.values.collect { |value|
@owner.send(:quote, value)
}.join(', ')
sql =
"INSERT INTO #{@join_table} (#{columns_list}) " +
"VALUES (#{values_list})"
@owner.connection.execute(sql)
return true
end
def delete_records(records)
if @user_id.nil?
raise "Cannot delete records unless the user id is set."
end
ids = quoted_record_ids(records)
sql = "DELETE FROM #{@join_table} " +
"WHERE #{@item_foreign_key} = " +
" #{@owner.quoted_id} " +
"AND #{@user_foreign_key} = '#{@user_id.to_s}'" +
"AND #{@tag_foreign_key} IN (#{ids})"
@owner.connection.execute(sql)
end
end
end
module Acts #:nodoc:
module Taggable #:nodoc:
def self.append_features(base) #:nodoc:
super
base.extend(ClassMethods)
end
module ClassMethods
def acts_as_taggable(options = {})
validate_options([ :scope, :tag_class_name, :user_class_name,
:item_foreign_key, :tag_foreign_key,
:user_foreign_key, :collection, :user_collection,
:conditions, :join_table, :before_add,
:after_add, :before_remove, :after_remove ],
options.keys)
options = { :scope => :global,
:collection => :tags,
:user_collection => :user_tags,
:tag_class_name => 'Tag',
:user_class_name => 'User' }.merge(options)
unless [:global, :user].include? options[:scope]
raise(ActiveRecord::ActiveRecordError,
":scope must either be set to :global or :user.")
end
require_association(
Inflector.underscore(options[:tag_class_name]))
tag_class = eval(options[:tag_class_name])
tag_foreign_key = options[:tag_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(tag_class.name)) + "_id"
item_class = self
item_foreign_key = options[:item_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(item_class.name)) + "_id"
# Make sure we can sort tags
unless tag_class.method_defined?("<=>")
tag_class.module_eval do
define_method("<=>") do |raw_tag|
if raw_tag.kind_of? self.class
return self.name <=> raw_tag.name
else
return self.name <=> raw_tag.to_s
end
end
end
end
if options[:scope] == :user
require_association(
Inflector.underscore(options[:user_class_name]))
user_class = eval(options[:user_class_name])
user_foreign_key = options[:user_foreign_key] ||
Inflector.underscore(
Inflector.demodulize(user_class.name)) + "_id"
options[:join_table] ||=
join_table_name(
undecorated_table_name(tag_class.name),
undecorated_table_name(user_class.name),
undecorated_table_name(item_class.name))
association_hash = {
:tag_class => tag_class,
:tag_foreign_key => tag_foreign_key,
:item_class => item_class,
:item_foreign_key => item_foreign_key,
:user_class => user_class,
:user_foreign_key => user_foreign_key,
:join_table => options[:join_table]
}
if tag_class.instance_variable_get("@tagged_classes").nil?
tag_class.instance_variable_set("@tagged_classes", [])
end
tagged_classes =
tag_class.instance_variable_get("@tagged_classes")
tagged_classes << association_hash
# Create the two collections
define_method(options[:collection]) do |*params|
force_reload = params.first unless params.empty?
association = instance_variable_get(
"@#{options[:collection]}")
unless association.respond_to?(:loaded?)
association =
ActiveRecord::Associations::UserTagsAssociation.new(self,
nil, tag_class, user_class, item_class, options)
instance_variable_set(
"@#{options[:collection]}", association)
end
association.reload if force_reload
return association
end
define_method(options[:user_collection]) do |user_id, *params|
unless user_id.kind_of? Fixnum
raise(ActiveRecord::ActiveRecordError,
"Expected Fixnum, got #{user_id.class.name}")
end
force_reload = params.first unless params.empty?
association = instance_variable_get(
"@#{options[:user_collection]}_#{user_id}")
unless association.respond_to?(:loaded?)
association =
ActiveRecord::Associations::UserTagsAssociation.new(self,
user_id, tag_class, user_class, item_class, options)
instance_variable_set(
"@#{options[:user_collection]}_#{user_id}", association)
end
association.reload if force_reload
return association
end
singleton_class = class << self; self; end
singleton_class.module_eval do
define_method(:tag_query) do |*params|
query_options = params.first unless params.empty?
unless query_options.kind_of? Hash
raise "The first parameter must be a hash."
end
validate_options([:with_any_tags, :with_all_tags,
:without_tags, :user_id], query_options.keys)
if query_options.size == 0
raise "You must supply either the with_any_tags option, " +
"the with_all_tags option, or both."
end
with_any_tags = query_options[:with_any_tags]
unless with_any_tags.nil?
with_any_tags.collect! { |tag| tag.to_s }
with_any_tags.uniq!
end
with_all_tags = query_options[:with_all_tags]
unless with_all_tags.nil?
with_all_tags.collect! { |tag| tag.to_s }
with_all_tags.uniq!
end
without_tags = query_options[:without_tags]
unless without_tags.nil?
without_tags.collect! { |tag| tag.to_s }
without_tags.uniq!
end
if without_tags != nil && with_any_tags == nil &&
with_all_tags == nil
raise(ActiveRecord::ActiveRecordError,
"Cannot run this query, nothing to search for.")
end
tagging_user_id = query_options[:user_id]
results = []
group_by_string = item_class.table_name + "." +
item_class.column_names.join(
", #{item_class.table_name}.")
tagging_user_id_string = ""
unless tagging_user_id.nil?
tagging_user_id_string =
"AND #{options[:join_table]}.#{user_foreign_key} = " +
"#{tagging_user_id}"
end
with_all_tags_results = nil
if with_all_tags != nil && with_all_tags.size > 0
tag_name_condition = "#{tag_class.table_name}.name = '" +
with_all_tags.join(
"\' OR #{tag_class.table_name}.name=\'") + "'"
with_all_tags_sql = <<-SQL
SELECT #{item_class.table_name}.*
FROM #{options[:join_table]}, #{item_class.table_name},
#{tag_class.table_name}
WHERE #{options[:join_table]}.#{tag_foreign_key} =
#{tag_class.table_name}.#{tag_class.primary_key}
AND (#{tag_name_condition})
AND #{item_class.table_name}.#{item_class.primary_key} =
#{options[:join_table]}.#{item_foreign_key}
#{tagging_user_id_string}
GROUP BY #{group_by_string}
HAVING COUNT(
#{item_class.table_name}.#{item_class.primary_key}) >=
#{with_all_tags.size}
SQL
with_all_tags_results =
item_class.find_by_sql(with_all_tags_sql)
if tagging_user_id.nil?
for result in with_all_tags_results
result_tags = result.tags.map do |tag|
tag.name
end
if (result_tags & with_all_tags).size != with_all_tags.size
# Reject result
with_all_tags_results.delete(result)
end
end
end
end
with_any_tags_results = nil
if with_any_tags != nil && with_any_tags.size > 0
with_any_tags_sql = <<-SQL
SELECT #{item_class.table_name}.*
FROM #{options[:join_table]}, #{item_class.table_name},
#{tag_class.table_name}
WHERE #{tag_class.table_name}.name
IN ('#{with_any_tags.join('\', \'')}')
AND #{tag_class.table_name}.#{tag_class.primary_key} =
#{options[:join_table]}.#{tag_foreign_key}
AND #{item_class.table_name}.#{item_class.primary_key} =
#{options[:join_table]}.#{item_foreign_key}
#{tagging_user_id_string}
GROUP BY #{group_by_string}
SQL
with_any_tags_results =
item_class.find_by_sql(with_any_tags_sql)
end
if with_any_tags_results != nil &&
with_any_tags_results.size > 0 &&
with_all_tags_results != nil &&
with_all_tags_results.size > 0
results = with_all_tags_results & with_any_tags_results
elsif with_any_tags_results != nil &&
with_any_tags_results.size > 0
results = with_any_tags_results
elsif with_all_tags_results != nil &&
with_all_tags_results.size > 0
results = with_all_tags_results
end
if without_tags != nil && without_tags.size > 0
for result in results
if tagging_user_id.nil?
if ((result.tags.map { |tag| tag.name }) &
without_tags).size > 0
results.delete(result)
end
else
if ((result.user_tags(tagging_user_id).map { |tag| tag.name }) &
without_tags).size > 0
results.delete(result)
end
end
end
end
return results
end
end
module_eval do
before_save <<-end_eval
@new_record_before_save = new_record?
associations =
instance_variables.inject([]) do |associations, iv|
if (iv =~ /^@#{options[:user_collection]}_/) == 0
associations << iv
end
associations
end
associations.each do |association_name|
association = instance_variable_get("\#{association_name}")
if association.respond_to?(:loaded?)
if new_record?
records_to_save = association
else
records_to_save = association.select do |record|
record.new_record?
end
end
records_to_save.inject(true) do |result,record|
result &&= record.valid?
end
end
end
end_eval
end
module_eval do
after_callback = <<-end_eval
associations =
instance_variables.inject([]) do |associations, iv|
if (iv =~ /^@#{options[:user_collection]}_/) == 0
associations << iv
end
associations
end
associations.each do |association_name|
association = instance_variable_get("\#{association_name}")
if association.respond_to?(:loaded?)
if @new_record_before_save
records_to_save = association
else
records_to_save = association.select do |record|
record.new_record?
end
end
records_to_save.each do |record|
association.send(:insert_record, record)
end
# reconstruct the SQL queries now that we know
# the owner's id
association.send(:construct_sql)
end
end
end_eval
# Doesn't use after_save as that would save associations
# added in after_create/after_update twice
after_create(after_callback)
after_update(after_callback)
end
# When the item gets destroyed, clear out all relationships
# that reference it.
before_destroy_sql = "DELETE FROM #{options[:join_table]} " +
"WHERE #{item_foreign_key} = " +
"\\\#{self.quoted_id}"
module_eval(
"before_destroy \"self.connection.delete(" +
"%{#{before_destroy_sql}})\"")
class_eval do
include ActiveRecord::Acts::Taggable::InstanceMethods
end
elsif options[:scope] == :global
require_association(
Inflector.underscore(options[:tag_class_name]))
tag_class = eval(options[:tag_class_name])
item_class = self
options[:join_table] ||=
join_table_name(
undecorated_table_name(tag_class.name),
undecorated_table_name(item_class.name))
association_hash = {
:tag_class => tag_class,
:tag_foreign_key => tag_foreign_key,
:item_class => item_class,
:item_foreign_key => item_foreign_key,
:user_class => nil,
:user_foreign_key => nil,
:join_table => options[:join_table]
}
if tag_class.instance_variable_get("@tagged_classes").nil?
tag_class.instance_variable_set("@tagged_classes", [])
end
tagged_classes =
tag_class.instance_variable_get("@tagged_classes")
tagged_classes << association_hash
define_method(options[:collection]) do |*params|
force_reload = params.first unless params.empty?
association = instance_variable_get(
"@#{options[:collection]}")
unless association.respond_to?(:loaded?)
association =
ActiveRecord::Associations::GlobalTagsAssociation.new(self,
tag_class, item_class, options)
instance_variable_set(
"@#{options[:collection]}", association)
end
association.reload if force_reload
return association
end
singleton_class = class << self; self; end
singleton_class.module_eval do
define_method(:tag_query) do |*params|
query_options = params.first unless params.empty?
unless query_options.kind_of? Hash
raise "The first parameter must be a hash."
end
validate_options([:with_any_tags, :with_all_tags,
:without_tags, :user_id], query_options.keys)
if query_options.size == 0
raise "You must supply either the with_any_tags option, " +
"the with_all_tags option, or both."
end
with_any_tags = query_options[:with_any_tags]
unless with_any_tags.nil?
with_any_tags.collect! { |tag| tag.to_s }
with_any_tags.uniq!
end
with_all_tags = query_options[:with_all_tags]
unless with_all_tags.nil?
with_all_tags.collect! { |tag| tag.to_s }
with_all_tags.uniq!
end
without_tags = query_options[:without_tags]
unless without_tags.nil?
without_tags.collect! { |tag| tag.to_s }
without_tags.uniq!
end
if without_tags != nil && with_any_tags == nil &&
with_all_tags == nil
raise(ActiveRecord::ActiveRecordError,
"Cannot run this query, nothing to search for.")
end
results = []
group_by_string = item_class.table_name + "." +
item_class.column_names.join(
", #{item_class.table_name}.")
with_all_tags_results = nil
if with_all_tags != nil && with_all_tags.size > 0
tag_name_condition = "#{tag_class.table_name}.name = '" +
with_all_tags.join(
"\' OR #{tag_class.table_name}.name=\'") + "'"
with_all_tags_sql = <<-SQL
SELECT #{item_class.table_name}.*
FROM #{options[:join_table]}, #{item_class.table_name},
#{tag_class.table_name}
WHERE #{options[:join_table]}.#{tag_foreign_key} =
#{tag_class.table_name}.#{tag_class.primary_key}
AND (#{tag_name_condition})
AND #{item_class.table_name}.#{item_class.primary_key} =
#{options[:join_table]}.#{item_foreign_key}
GROUP BY #{group_by_string}
HAVING COUNT(
#{item_class.table_name}.#{item_class.primary_key}) =
#{with_all_tags.size}
SQL
with_all_tags_results =
item_class.find_by_sql(with_all_tags_sql)
end
with_any_tags_results = nil
if with_any_tags != nil && with_any_tags.size > 0
with_any_tags_sql = <<-SQL
SELECT #{item_class.table_name}.*
FROM #{options[:join_table]}, #{item_class.table_name},
#{tag_class.table_name}
WHERE #{tag_class.table_name}.name
IN ('#{with_any_tags.join('\', \'')}')
AND #{tag_class.table_name}.#{tag_class.primary_key} =
#{options[:join_table]}.#{tag_foreign_key}
AND #{item_class.table_name}.#{item_class.primary_key} =
#{options[:join_table]}.#{item_foreign_key}
GROUP BY #{group_by_string}
SQL
with_any_tags_results =
item_class.find_by_sql(with_any_tags_sql)
end
if with_any_tags_results != nil &&
with_any_tags_results.size > 0 &&
with_all_tags_results != nil &&
with_all_tags_results.size > 0
results = with_all_tags_results & with_any_tags_results
elsif with_any_tags_results != nil &&
with_any_tags_results.size > 0
results = with_any_tags_results
elsif with_all_tags_results != nil &&
with_all_tags_results.size > 0
results = with_all_tags_results
end
if without_tags != nil && without_tags.size > 0
for result in results
if ((result.tags.map { |tag| tag.name }) &
without_tags).size > 0
results.delete(result)
end
end
end
return results
end
end
module_eval do
before_save <<-end_eval
@new_record_before_save = new_record?
association =
instance_variable_get("@#{options[:collection]}")
if association.respond_to?(:loaded?)
if new_record?
records_to_save = association
else
records_to_save = association.select do |record|
record.new_record?
end
end
records_to_save.inject(true) do |result,record|
result &&= record.valid?
end
end
end_eval
end
module_eval do
after_callback = <<-end_eval
association =
instance_variable_get("@#{options[:collection]}")
if association.respond_to?(:loaded?)
if @new_record_before_save
records_to_save = association
else
records_to_save = association.select do |record|
record.new_record?
end
end
records_to_save.each do |record|
association.send(:insert_record, record)
end
# reconstruct the SQL queries now that we know
# the owner's id
association.send(:construct_sql)
end
end_eval
# Doesn't use after_save as that would save associations
# added in after_create/after_update twice
after_create(after_callback)
after_update(after_callback)
end
# When the item gets destroyed, clear out all relationships
# that reference it.
before_destroy_sql = "DELETE FROM #{options[:join_table]} " +
"WHERE #{item_foreign_key} = " +
"\\\#{self.quoted_id}"
module_eval(
"before_destroy \"self.connection.delete(" +
"%{#{before_destroy_sql}})\"")
class_eval do
include ActiveRecord::Acts::Taggable::InstanceMethods
end
end
end
private
private
# Raises an exception if an invalid option has been specified to
# prevent misspellings from slipping through
def validate_options(valid_option_keys, supplied_option_keys)
unknown_option_keys = supplied_option_keys - valid_option_keys
unless unknown_option_keys.empty?
raise(ActiveRecord::ActiveRecordError,
"Unknown options: #{unknown_option_keys}")
end
end
def join_table_name(*table_names)
table_name_prefix + table_names.sort.join("_") + table_name_suffix
end
end
module InstanceMethods
end
end
end
end
# This module allows you to add additional generic functionality to your
# Tag class by simply extending the TaggingHelpers module.
#
# Example:
# class Tag < ActiveRecord::Base
# extend TaggingHelpers
# end
module TaggingHelpers
# Unlike the tag_query class method on tagged classes, this will search
# across the entire tagged space, returning any tagged object, regardless
# of type, that matches the query criteria.
#
# Options are:
# * :with_all_tags - An array of strings, returns all tagged
# objects that have all of the tags specified within the array.
# * :with_any_tags - An array of strings, returns all tagged
# objects that have any of the tags specified within the array.
# * :without_tags - An array of strings, removes all tagged
# objects that have any of the tags specified within the array from
# the list of results that would have otherwise been returned.
# Throws an error if used in the absence of of either of the other
# two options.
# * :user_id - An integer id for the user that this query
# is specific to. Only tags that this user has created will be
# taken into account with this query. Globally scoped tag associations
# will be not be included in the results if this option is set.
def tag_query(options = {})
if self.class != Class
raise "You must extend your tag class with the TaggingHelpers module."
end
unless options.kind_of? Hash
raise "The options parameter must be a hash."
end
validate_options([:with_any_tags, :with_all_tags,
:without_tags, :user_id], options.keys)
if options.size == 0
raise "You must supply either the with_any_tags option, " +
"the with_all_tags option, or both."
end
with_any_tags = options[:with_any_tags]
unless with_any_tags.nil?
with_any_tags.collect! { |tag| tag.to_s }
with_any_tags.uniq!
end
with_all_tags = options[:with_all_tags]
unless with_all_tags.nil?
with_all_tags.collect! { |tag| tag.to_s }
with_all_tags.uniq!
end
without_tags = options[:without_tags]
unless without_tags.nil?
without_tags.collect! { |tag| tag.to_s }
without_tags.uniq!
end
if without_tags != nil && with_any_tags == nil &&
with_all_tags == nil
raise(ActiveRecord::ActiveRecordError,
"Cannot run this query, nothing to search for.")
end
tagging_user_id = options[:user_id]
results = []
for association_hash in self.instance_variable_get("@tagged_classes")
# Load variables from the association_hash
tag_class = association_hash[:tag_class]
tag_foreign_key = association_hash[:tag_foreign_key]
item_class = association_hash[:item_class]
item_foreign_key = association_hash[:item_foreign_key]
user_class = association_hash[:user_class]
user_foreign_key = association_hash[:user_foreign_key]
join_table = association_hash[:join_table]
# If they specified a user_id, and this association is a globally
# scoped tag association, skip to the next association.
if tagging_user_id != nil && user_class.nil?
next
end
group_by_string = item_class.table_name + "." +
item_class.column_names.join(
", #{item_class.table_name}.")
tagging_user_id_string = ""
unless tagging_user_id.nil?
tagging_user_id_string =
"AND #{join_table}.#{user_foreign_key} = #{tagging_user_id}"
end
with_all_tags_results = nil
if with_all_tags != nil && with_all_tags.size > 0
tag_name_condition = "#{tag_class.table_name}.name = '" +
with_all_tags.join(
"\' OR #{tag_class.table_name}.name=\'") + "'"
with_all_tags_sql = <<-SQL
SELECT #{item_class.table_name}.*
FROM #{join_table}, #{item_class.table_name},
#{tag_class.table_name}
WHERE #{join_table}.#{tag_foreign_key} =
#{tag_class.table_name}.#{tag_class.primary_key}
AND (#{tag_name_condition})
AND #{item_class.table_name}.#{item_class.primary_key} =
#{join_table}.#{item_foreign_key}
#{tagging_user_id_string}
GROUP BY #{group_by_string}
HAVING COUNT(
#{item_class.table_name}.#{item_class.primary_key}) >=
#{with_all_tags.size}
SQL
with_all_tags_results =
item_class.find_by_sql(with_all_tags_sql)
if tagging_user_id.nil?
for result in with_all_tags_results
result_tags = result.tags.map do |tag|
tag.name
end
if (result_tags & with_all_tags).size != with_all_tags.size
# Reject result
with_all_tags_results.delete(result)
end
end
end
end
with_any_tags_results = nil
if with_any_tags != nil && with_any_tags.size > 0
with_any_tags_sql = <<-SQL
SELECT #{item_class.table_name}.*
FROM #{join_table}, #{item_class.table_name},
#{tag_class.table_name}
WHERE #{tag_class.table_name}.name
IN ('#{with_any_tags.join('\', \'')}')
AND #{tag_class.table_name}.#{tag_class.primary_key} =
#{join_table}.#{tag_foreign_key}
AND #{item_class.table_name}.#{item_class.primary_key} =
#{join_table}.#{item_foreign_key}
#{tagging_user_id_string}
GROUP BY #{group_by_string}
SQL
with_any_tags_results =
item_class.find_by_sql(with_any_tags_sql)
end
if with_any_tags_results != nil &&
with_any_tags_results.size > 0 &&
with_all_tags_results != nil &&
with_all_tags_results.size > 0
results.concat(with_all_tags_results & with_any_tags_results)
elsif with_any_tags_results != nil &&
with_any_tags_results.size > 0
results.concat(with_any_tags_results)
elsif with_all_tags_results != nil &&
with_all_tags_results.size > 0
results.concat(with_all_tags_results)
end
end
if without_tags != nil && without_tags.size > 0
for result in results
if tagging_user_id.nil?
if ((result.tags.map { |tag| tag.name }) &
without_tags).size > 0
results.delete(result)
end
else
if ((result.user_tags(tagging_user_id).map { |tag| tag.name }) &
without_tags).size > 0
results.delete(result)
end
end
end
end
return results
end
# Takes a string and converts it to a tag object, creating the tag object
# if it doesn't yet exist.
def create_tag(raw_tag)
if self.class != Class
raise "You must extend your tag class with the TaggingHelpers module."
end
return nil if raw_tag.nil?
if raw_tag.kind_of? self
return raw_tag
end
tag_object = self.find_by_name(raw_tag.to_s)
if tag_object.nil?
tag_object = self.new
tag_object.name = raw_tag.to_s
tag_object.save
end
return tag_object
end
# Takes a string and converts it to a tag object, returning nil if it
# doesn't yet exist.
def get_tag(raw_tag)
if self.class != Class
raise "You must extend your tag class with the TaggingHelpers module."
end
return nil if raw_tag.nil?
if raw_tag.kind_of? self
return raw_tag
end
tag_object = self.find_by_name(raw_tag.to_s)
return tag_object
end
end
ActiveRecord::Base.class_eval do
include ActiveRecord::Acts::Taggable
end