#-- # 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