From 65d35365994a025c7745a31d47b74a6c9a43b0f1 Mon Sep 17 00:00:00 2001 From: Reinier Balt Date: Sun, 21 Dec 2008 23:15:55 +0100 Subject: [PATCH] update tagging model --- app/models/tag.rb | 58 ++++++++-- app/models/tagging.rb | 28 +++-- lib/activerecord_base_tag_extensions.rb | 30 ----- lib/tagging_extensions.rb | 147 ++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 52 deletions(-) delete mode 100644 lib/activerecord_base_tag_extensions.rb create mode 100644 lib/tagging_extensions.rb diff --git a/app/models/tag.rb b/app/models/tag.rb index 81684c13..58a12f93 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,11 +1,47 @@ -class Tag < ActiveRecord::Base - has_many_polymorphs :taggables, - :from => [:todos, :recurring_todos], - :through => :taggings, - :dependent => :destroy - - def on(taggable, user) - tagging = taggings.create :taggable => taggable, :user => user - end - -end \ No newline at end of file + +# The Tag model. This model is automatically generated and added to your app if +# you run the tagging generator included with has_many_polymorphs. + +class Tag < ActiveRecord::Base + + DELIMITER = "," # Controls how to split and join tagnames from strings. You may need to change the validates_format_of parameters if you change this. + + # If database speed becomes an issue, you could remove these validations and + # rescue the ActiveRecord database constraint errors instead. + validates_presence_of :name + validates_uniqueness_of :name, :case_sensitive => false + + # Change this validation if you need more complex tag names. + validates_format_of :name, :with => /^[a-zA-Z0-9\_\-]+$/, :message => "can not contain special characters" + + # Set up the polymorphic relationship. + has_many_polymorphs :taggables, + :from => [:todos, :recurring_todos], + :through => :taggings, + :dependent => :destroy, + :skip_duplicates => false, + :parent_extend => proc { + # Defined on the taggable models, not on Tag itself. Return the tagnames + # associated with this record as a string. + def to_s + self.map(&:name).sort.join(Tag::DELIMITER) + end + } + + # Callback to strip extra spaces from the tagname before saving it. If you + # allow tags to be renamed later, you might want to use the + # before_save callback instead. + def before_create + self.name = name.downcase.strip.squeeze(" ") + end + + def on(taggable, user) + taggings.create :taggable => taggable, :user => user + end + + # Tag::Error class. Raised by ActiveRecord::Base::TaggingExtensions if + # something goes wrong. + class Error < StandardError + end + +end diff --git a/app/models/tagging.rb b/app/models/tagging.rb index 16c1d5da..b1879e94 100644 --- a/app/models/tagging.rb +++ b/app/models/tagging.rb @@ -1,11 +1,17 @@ -class Tagging < ActiveRecord::Base - belongs_to :tag - belongs_to :taggable, :polymorphic => true - belongs_to :user - - # def before_destroy - # # disallow orphaned tags - # # TODO: this doesn't seem to be working - # tag.destroy if tag.taggings.count < 2 - # end -end + +# The Tagging join model. This model is automatically generated and added to your app if you run the tagging generator included with has_many_polymorphs. + +class Tagging < ActiveRecord::Base + + belongs_to :tag + belongs_to :taggable, :polymorphic => true + belongs_to :user + + # If you also need to use acts_as_list, you will have to manage the tagging positions manually by creating decorated join records when you associate Tags with taggables. + # acts_as_list :scope => :taggable + + # This callback makes sure that an orphaned Tag is deleted if it no longer tags anything. + def after_destroy + tag.destroy_without_callbacks if tag and tag.taggings.count == 0 + end +end diff --git a/lib/activerecord_base_tag_extensions.rb b/lib/activerecord_base_tag_extensions.rb deleted file mode 100644 index 5361f73e..00000000 --- a/lib/activerecord_base_tag_extensions.rb +++ /dev/null @@ -1,30 +0,0 @@ -class ActiveRecord::Base - # These methods will work for any model instances - - # Tag with deletes all the current tags before adding the new ones - # This makes the edit form more intiuitive: - # Whatever is in the tags text field is what gets set as the tags for that action - # If you submit an empty tags text field, all the tags are removed. - def tag_with(tags, user) - Tag.transaction do - Tagging.delete_all("taggable_id = #{self.id} and taggable_type = '#{self.class}' and user_id = #{user.id}") - tags.downcase.split(",").each do |tag| - Tag.find_or_create_by_name(tag.strip).on(self, user) - end - end - end - - def tag_list - tags.map(&:name).join(', ') - end - - def delete_tags tag_string - split = tag_string.downcase.split(",") - tags.delete tags.select{|t| split.include? t.name.strip} - end - - def add_tag tag_name - Tag.find_or_create_by_name(tag_name.strip).on(self,user) - end - -end \ No newline at end of file diff --git a/lib/tagging_extensions.rb b/lib/tagging_extensions.rb new file mode 100644 index 00000000..5ef0e061 --- /dev/null +++ b/lib/tagging_extensions.rb @@ -0,0 +1,147 @@ + +class ActiveRecord::Base #:nodoc: + + # These extensions make models taggable. This file is automatically generated and required by your app if you run the tagging generator included with has_many_polymorphs. + module TaggingExtensions + + # Add tags to self. Accepts a string of tagnames, an array of tagnames, an array of ids, or an array of Tags. + # + # We need to avoid name conflicts with the built-in ActiveRecord association methods, thus the underscores. + def _add_tags incoming, user + taggable?(true) + tag_cast_to_string(incoming).each do |tag_name| + begin + tag = Tag.find_or_create_by_name(tag_name).on(self,user) + raise Tag::Error, "tag could not be saved: #{tag_name}" if tag.new_record? + tag.taggables << self + rescue ActiveRecord::StatementInvalid => e + raise unless e.to_s =~ /duplicate/i + end + end + end + + # Removes tags from self. Accepts a string of tagnames, an array of tagnames, an array of ids, or an array of Tags. + def _remove_tags outgoing, user + taggable?(true) + outgoing = tag_cast_to_string(outgoing) + + tags.delete(*(tags.select do |tag| + outgoing.include? tag.name + end)) + end + + # Returns the tags on self as a string. + def tag_list + # Redefined later to avoid an RDoc parse error. + end + + # Replace the existing tags on self. Accepts a string of tagnames, an array of tagnames, an array of ids, or an array of Tags. + def tag_with list, user + #:stopdoc: + taggable?(true) + list = tag_cast_to_string(list) + + # Transactions may not be ideal for you here; be aware. + Tag.transaction do + current = tags.map(&:name) + _add_tags(list - current, user) + _remove_tags(current - list, user) + end + + self + #:startdoc: + end + + # Returns the tags on self as a string. + def tag_list #:nodoc: + #:stopdoc: + taggable?(true) + tags.reload + tags.to_s + #:startdoc: + end + + private + + def tag_cast_to_string obj #:nodoc: + case obj + when Array + obj.map! do |item| + case item + when /^\d+$/, Fixnum then Tag.find(item).name # This will be slow if you use ids a lot. + when Tag then item.name + when String then item + else + raise "Invalid type" + end + end + when String + obj = obj.split(Tag::DELIMITER).map do |tag_name| + tag_name.strip.squeeze(" ") + end + else + raise "Invalid object of class #{obj.class} as tagging method parameter" + end.flatten.compact.map(&:downcase).uniq + end + + # Check if a model is in the :taggables target list. The alternative to this check is to explicitly include a TaggingMethods module (which you would create) in each target model. + def taggable?(should_raise = false) #:nodoc: + unless flag = respond_to?(:tags) + raise "#{self.class} is not a taggable model" if should_raise + end + flag + end + + end + + module TaggingFinders + # + # Find all the objects tagged with the supplied list of tags + # + # Usage : Model.tagged_with("ruby") + # Model.tagged_with("hello", "world") + # Model.tagged_with("hello", "world", :limit => 10) + # + def tagged_with(*tag_list) + options = tag_list.last.is_a?(Hash) ? tag_list.pop : {} + tag_list = parse_tags(tag_list) + + scope = scope(:find) + options[:select] ||= "#{table_name}.*" + options[:from] ||= "#{table_name}, tags, taggings" + + sql = "SELECT #{(scope && scope[:select]) || options[:select]} " + sql << "FROM #{(scope && scope[:from]) || options[:from]} " + + add_joins!(sql, options, scope) + + sql << "WHERE #{table_name}.#{primary_key} = taggings.taggable_id " + sql << "AND taggings.taggable_type = '#{ActiveRecord::Base.send(:class_name_of_active_record_descendant, self).to_s}' " + sql << "AND taggings.tag_id = tags.id " + + tag_list_condition = tag_list.map {|t| "'#{t}'"}.join(", ") + + sql << "AND (tags.name IN (#{sanitize_sql(tag_list_condition)})) " + sql << "AND #{sanitize_sql(options[:conditions])} " if options[:conditions] + sql << "GROUP BY #{table_name}.id " + sql << "HAVING COUNT(taggings.tag_id) = #{tag_list.size}" + + add_order!(sql, options[:order], scope) + add_limit!(sql, options, scope) + add_lock!(sql, options, scope) + + find_by_sql(sql) + end + + def parse_tags(tags) + return [] if tags.blank? + tags = Array(tags).first + tags = tags.respond_to?(:flatten) ? tags.flatten : tags.split(Tag::DELIMITER) + tags.map { |tag| tag.strip.squeeze(" ") }.flatten.compact.map(&:downcase).uniq + end + + end + + include TaggingExtensions + extend TaggingFinders +end