acts_as_taggable_on 3.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +9 -0
  5. data/Appraisals +7 -0
  6. data/Gemfile +5 -0
  7. data/Guardfile +5 -0
  8. data/LICENSE.md +20 -0
  9. data/README.md +309 -0
  10. data/Rakefile +13 -0
  11. data/UPGRADING +7 -0
  12. data/acts_as_taggable_on.gemspec +35 -0
  13. data/db/migrate/1_acts_as_taggable_on_migration.rb +30 -0
  14. data/db/migrate/2_add_missing_unique_indices.rb +21 -0
  15. data/gemfiles/rails_3.gemfile +8 -0
  16. data/gemfiles/rails_4.gemfile +8 -0
  17. data/lib/acts_as_taggable_on.rb +61 -0
  18. data/lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb +82 -0
  19. data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +187 -0
  20. data/lib/acts_as_taggable_on/acts_as_taggable_on/compatibility.rb +34 -0
  21. data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +394 -0
  22. data/lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb +37 -0
  23. data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +135 -0
  24. data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +84 -0
  25. data/lib/acts_as_taggable_on/engine.rb +6 -0
  26. data/lib/acts_as_taggable_on/tag.rb +119 -0
  27. data/lib/acts_as_taggable_on/tag_list.rb +101 -0
  28. data/lib/acts_as_taggable_on/taggable.rb +105 -0
  29. data/lib/acts_as_taggable_on/tagger.rb +76 -0
  30. data/lib/acts_as_taggable_on/tagging.rb +34 -0
  31. data/lib/acts_as_taggable_on/tags_helper.rb +15 -0
  32. data/lib/acts_as_taggable_on/utils.rb +34 -0
  33. data/lib/acts_as_taggable_on/version.rb +4 -0
  34. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +265 -0
  35. data/spec/acts_as_taggable_on/acts_as_tagger_spec.rb +114 -0
  36. data/spec/acts_as_taggable_on/caching_spec.rb +77 -0
  37. data/spec/acts_as_taggable_on/related_spec.rb +143 -0
  38. data/spec/acts_as_taggable_on/single_table_inheritance_spec.rb +187 -0
  39. data/spec/acts_as_taggable_on/tag_list_spec.rb +126 -0
  40. data/spec/acts_as_taggable_on/tag_spec.rb +211 -0
  41. data/spec/acts_as_taggable_on/taggable_spec.rb +623 -0
  42. data/spec/acts_as_taggable_on/tagger_spec.rb +137 -0
  43. data/spec/acts_as_taggable_on/tagging_spec.rb +28 -0
  44. data/spec/acts_as_taggable_on/tags_helper_spec.rb +44 -0
  45. data/spec/acts_as_taggable_on/utils_spec.rb +21 -0
  46. data/spec/bm.rb +52 -0
  47. data/spec/database.yml.sample +19 -0
  48. data/spec/models.rb +58 -0
  49. data/spec/schema.rb +65 -0
  50. data/spec/spec_helper.rb +87 -0
  51. metadata +248 -0
@@ -0,0 +1,37 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Dirty
3
+ def self.included(base)
4
+ base.extend ActsAsTaggableOn::Taggable::Dirty::ClassMethods
5
+
6
+ base.initialize_acts_as_taggable_on_dirty
7
+ end
8
+
9
+ module ClassMethods
10
+ def initialize_acts_as_taggable_on_dirty
11
+ tag_types.map(&:to_s).each do |tags_type|
12
+ tag_type = tags_type.to_s.singularize
13
+ context_tags = tags_type.to_sym
14
+
15
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
16
+ def #{tag_type}_list_changed?
17
+ changed_attributes.include?("#{tag_type}_list")
18
+ end
19
+
20
+ def #{tag_type}_list_was
21
+ changed_attributes.include?("#{tag_type}_list") ? changed_attributes["#{tag_type}_list"] : __send__("#{tag_type}_list")
22
+ end
23
+
24
+ def #{tag_type}_list_change
25
+ [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list")
26
+ end
27
+
28
+ def #{tag_type}_list_changes
29
+ [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list")
30
+ end
31
+ RUBY
32
+
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,135 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Ownership
3
+ def self.included(base)
4
+ base.send :include, ActsAsTaggableOn::Taggable::Ownership::InstanceMethods
5
+ base.extend ActsAsTaggableOn::Taggable::Ownership::ClassMethods
6
+
7
+ base.class_eval do
8
+ after_save :save_owned_tags
9
+ end
10
+
11
+ base.initialize_acts_as_taggable_on_ownership
12
+ end
13
+
14
+ module ClassMethods
15
+ def acts_as_taggable_on(*args)
16
+ initialize_acts_as_taggable_on_ownership
17
+ super(*args)
18
+ end
19
+
20
+ def initialize_acts_as_taggable_on_ownership
21
+ tag_types.map(&:to_s).each do |tag_type|
22
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
23
+ def #{tag_type}_from(owner)
24
+ owner_tag_list_on(owner, '#{tag_type}')
25
+ end
26
+ RUBY
27
+ end
28
+ end
29
+ end
30
+
31
+ module InstanceMethods
32
+ def owner_tags_on(owner, context)
33
+ if owner.nil?
34
+ scope = base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ?), context.to_s])
35
+ else
36
+ scope = base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND
37
+ #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = ? AND
38
+ #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = ?), context.to_s, owner.id, owner.class.base_class.to_s])
39
+ end
40
+
41
+ # when preserving tag order, return tags in created order
42
+ # if we added the order to the association this would always apply
43
+ if self.class.preserve_tag_order?
44
+ scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id")
45
+ else
46
+ scope
47
+ end
48
+ end
49
+
50
+ def cached_owned_tag_list_on(context)
51
+ variable_name = "@owned_#{context}_list"
52
+ cache = (instance_variable_defined?(variable_name) && instance_variable_get(variable_name)) || instance_variable_set(variable_name, {})
53
+ end
54
+
55
+ def owner_tag_list_on(owner, context)
56
+ add_custom_context(context)
57
+
58
+ cache = cached_owned_tag_list_on(context)
59
+
60
+ cache[owner] ||= ActsAsTaggableOn::TagList.new(*owner_tags_on(owner, context).map(&:name))
61
+ end
62
+
63
+ def set_owner_tag_list_on(owner, context, new_list)
64
+ add_custom_context(context)
65
+
66
+ cache = cached_owned_tag_list_on(context)
67
+
68
+ cache[owner] = ActsAsTaggableOn::TagList.from(new_list)
69
+ end
70
+
71
+ def reload(*args)
72
+ self.class.tag_types.each do |context|
73
+ instance_variable_set("@owned_#{context}_list", nil)
74
+ end
75
+
76
+ super(*args)
77
+ end
78
+
79
+ def save_owned_tags
80
+ tagging_contexts.each do |context|
81
+ cached_owned_tag_list_on(context).each do |owner, tag_list|
82
+
83
+ # Find existing tags or create non-existing tags:
84
+ tags = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list.uniq)
85
+
86
+ # Tag objects for owned tags
87
+ owned_tags = owner_tags_on(owner, context)
88
+
89
+ # Tag maintenance based on whether preserving the created order of tags
90
+ if self.class.preserve_tag_order?
91
+ old_tags, new_tags = owned_tags - tags, tags - owned_tags
92
+
93
+ shared_tags = owned_tags & tags
94
+
95
+ if shared_tags.any? && tags[0...shared_tags.size] != shared_tags
96
+ index = shared_tags.each_with_index { |_, i| break i unless shared_tags[i] == tags[i] }
97
+
98
+ # Update arrays of tag objects
99
+ old_tags |= owned_tags.from(index)
100
+ new_tags |= owned_tags.from(index) & shared_tags
101
+
102
+ # Order the array of tag objects to match the tag list
103
+ new_tags = tags.map { |t| new_tags.find { |n| n.name.downcase == t.name.downcase } }.compact
104
+ end
105
+ else
106
+ # Delete discarded tags and create new tags
107
+ old_tags = owned_tags - tags
108
+ new_tags = tags - owned_tags
109
+ end
110
+
111
+ # Find all taggings that belong to the taggable (self), are owned by the owner,
112
+ # have the correct context, and are removed from the list.
113
+ if old_tags.present?
114
+ old_taggings = ActsAsTaggableOn::Tagging.where(:taggable_id => id, :taggable_type => self.class.base_class.to_s,
115
+ :tagger_type => owner.class.base_class.to_s, :tagger_id => owner.id,
116
+ :tag_id => old_tags, :context => context)
117
+ end
118
+
119
+ # Destroy old taggings:
120
+ if old_taggings.present?
121
+ ActsAsTaggableOn::Tagging.destroy_all(:id => old_taggings.map(&:id))
122
+ end
123
+
124
+ # Create new taggings:
125
+ new_tags.each do |tag|
126
+ taggings.create!(:tag_id => tag.id, :context => context.to_s, :tagger => owner, :taggable => self)
127
+ end
128
+ end
129
+ end
130
+
131
+ true
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,84 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Related
3
+ def self.included(base)
4
+ base.send :include, ActsAsTaggableOn::Taggable::Related::InstanceMethods
5
+ base.extend ActsAsTaggableOn::Taggable::Related::ClassMethods
6
+ base.initialize_acts_as_taggable_on_related
7
+ end
8
+
9
+ module ClassMethods
10
+ def initialize_acts_as_taggable_on_related
11
+ tag_types.map(&:to_s).each do |tag_type|
12
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ def find_related_#{tag_type}(options = {})
14
+ related_tags_for('#{tag_type}', self.class, options)
15
+ end
16
+ alias_method :find_related_on_#{tag_type}, :find_related_#{tag_type}
17
+
18
+ def find_related_#{tag_type}_for(klass, options = {})
19
+ related_tags_for('#{tag_type}', klass, options)
20
+ end
21
+ RUBY
22
+ end
23
+
24
+ unless tag_types.empty?
25
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
26
+ def find_matching_contexts(search_context, result_context, options = {})
27
+ matching_contexts_for(search_context.to_s, result_context.to_s, self.class, options)
28
+ end
29
+
30
+ def find_matching_contexts_for(klass, search_context, result_context, options = {})
31
+ matching_contexts_for(search_context.to_s, result_context.to_s, klass, options)
32
+ end
33
+ RUBY
34
+ end
35
+ end
36
+
37
+ def acts_as_taggable_on(*args)
38
+ super(*args)
39
+ initialize_acts_as_taggable_on_related
40
+ end
41
+ end
42
+
43
+ module InstanceMethods
44
+ def matching_contexts_for(search_context, result_context, klass, options = {})
45
+ tags_to_find = tags_on(search_context).collect { |t| t.name }
46
+
47
+ klass.select("#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}) AS count") \
48
+ .from("#{klass.table_name}, #{ActsAsTaggableOn::Tag.table_name}, #{ActsAsTaggableOn::Tagging.table_name}") \
49
+ .where(["#{exclude_self(klass, id)} #{klass.table_name}.#{klass.primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = '#{klass.base_class.to_s}' AND #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND #{ActsAsTaggableOn::Tag.table_name}.name IN (?) AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", tags_to_find, result_context]) \
50
+ .group(group_columns(klass)) \
51
+ .order("count DESC")
52
+ end
53
+
54
+ def related_tags_for(context, klass, options = {})
55
+ tags_to_ignore = Array.wrap(options.delete(:ignore)).map(&:to_s) || []
56
+ tags_to_find = tags_on(context).collect { |t| t.name }.reject { |t| tags_to_ignore.include? t }
57
+
58
+ klass.select("#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}) AS count") \
59
+ .from("#{klass.table_name}, #{ActsAsTaggableOn::Tag.table_name}, #{ActsAsTaggableOn::Tagging.table_name}") \
60
+ .where(["#{exclude_self(klass, id)} #{klass.table_name}.#{klass.primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = '#{klass.base_class.to_s}' AND #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND #{ActsAsTaggableOn::Tag.table_name}.name IN (?)", tags_to_find]) \
61
+ .group(group_columns(klass)) \
62
+ .order("count DESC")
63
+ end
64
+
65
+ private
66
+
67
+ def exclude_self(klass, id)
68
+ if [self.class.base_class, self.class].include? klass
69
+ "#{klass.table_name}.#{klass.primary_key} != #{id} AND"
70
+ else
71
+ nil
72
+ end
73
+ end
74
+
75
+ def group_columns(klass)
76
+ if ActsAsTaggableOn::Tag.using_postgresql?
77
+ grouped_column_names_for(klass)
78
+ else
79
+ "#{klass.table_name}.#{klass.primary_key}"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,6 @@
1
+ require 'rails/engine'
2
+ module ActsAsTaggableOn
3
+ class Engine < Rails::Engine
4
+
5
+ end
6
+ end
@@ -0,0 +1,119 @@
1
+ # coding: utf-8
2
+ module ActsAsTaggableOn
3
+ class Tag < ::ActiveRecord::Base
4
+ include ActsAsTaggableOn::Utils
5
+
6
+ attr_accessible :name if defined?(ActiveModel::MassAssignmentSecurity)
7
+
8
+ ### ASSOCIATIONS:
9
+
10
+ has_many :taggings, :dependent => :destroy, :class_name => 'ActsAsTaggableOn::Tagging'
11
+
12
+ ### VALIDATIONS:
13
+
14
+ validates_presence_of :name
15
+ validates_uniqueness_of :name, :if => :validates_name_uniqueness?
16
+ validates_length_of :name, :maximum => 255
17
+
18
+ # monkey patch this method if don't need name uniqueness validation
19
+ def validates_name_uniqueness?
20
+ true
21
+ end
22
+
23
+ ### SCOPES:
24
+
25
+ def self.named(name)
26
+ if ActsAsTaggableOn.strict_case_match
27
+ where(["name = #{binary}?", name])
28
+ else
29
+ where(["lower(name) = ?", name.downcase])
30
+ end
31
+ end
32
+
33
+ def self.named_any(list)
34
+ if ActsAsTaggableOn.strict_case_match
35
+ clause = list.map { |tag|
36
+ sanitize_sql(["name = #{binary}?", as_8bit_ascii(tag)])
37
+ }.join(" OR ")
38
+ where(clause)
39
+ else
40
+ clause = list.map { |tag|
41
+ lowercase_ascii_tag = as_8bit_ascii(tag).downcase
42
+ sanitize_sql(["lower(name) = ?", lowercase_ascii_tag])
43
+ }.join(" OR ")
44
+ where(clause)
45
+ end
46
+ end
47
+
48
+ def self.named_like(name)
49
+ clause = ["name #{like_operator} ? ESCAPE '!'", "%#{escape_like(name)}%"]
50
+ where(clause)
51
+ end
52
+
53
+ def self.named_like_any(list)
54
+ clause = list.map { |tag|
55
+ sanitize_sql(["name #{like_operator} ? ESCAPE '!'", "%#{escape_like(tag.to_s)}%"])
56
+ }.join(" OR ")
57
+ where(clause)
58
+ end
59
+
60
+ ### CLASS METHODS:
61
+
62
+ def self.find_or_create_with_like_by_name(name)
63
+ if (ActsAsTaggableOn.strict_case_match)
64
+ self.find_or_create_all_with_like_by_name([name]).first
65
+ else
66
+ named_like(name).first || create(:name => name)
67
+ end
68
+ end
69
+
70
+ def self.find_or_create_all_with_like_by_name(*list)
71
+ list = Array(list).flatten
72
+
73
+ return [] if list.empty?
74
+
75
+ existing_tags = Tag.named_any(list)
76
+
77
+ list.map do |tag_name|
78
+ comparable_tag_name = comparable_name(tag_name)
79
+ existing_tag = existing_tags.detect { |tag| comparable_name(tag.name) == comparable_tag_name }
80
+
81
+ existing_tag || Tag.create(:name => tag_name)
82
+ end
83
+ end
84
+
85
+ ### INSTANCE METHODS:
86
+
87
+ def ==(object)
88
+ super || (object.is_a?(Tag) && name == object.name)
89
+ end
90
+
91
+ def to_s
92
+ name
93
+ end
94
+
95
+ def count
96
+ read_attribute(:count).to_i
97
+ end
98
+
99
+ class << self
100
+ private
101
+
102
+ def comparable_name(str)
103
+ as_8bit_ascii(str).downcase
104
+ end
105
+
106
+ def binary
107
+ /mysql/ === ActiveRecord::Base.connection_config[:adapter] ? "BINARY " : nil
108
+ end
109
+
110
+ def as_8bit_ascii(string)
111
+ if defined?(Encoding)
112
+ string.to_s.force_encoding('BINARY')
113
+ else
114
+ string.to_s.mb_chars
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,101 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+
3
+ module ActsAsTaggableOn
4
+ class TagList < Array
5
+ attr_accessor :owner
6
+
7
+ def initialize(*args)
8
+ add(*args)
9
+ end
10
+
11
+ ##
12
+ # Returns a new TagList using the given tag string.
13
+ #
14
+ # Example:
15
+ # tag_list = TagList.from("One , Two, Three")
16
+ # tag_list # ["One", "Two", "Three"]
17
+ def self.from(string)
18
+ string = string.join(ActsAsTaggableOn.glue) if string.respond_to?(:join)
19
+
20
+ new.tap do |tag_list|
21
+ string = string.to_s.dup
22
+
23
+ # Parse the quoted tags
24
+ d = ActsAsTaggableOn.delimiter
25
+ d = d.join("|") if d.kind_of?(Array)
26
+ string.gsub!(/(\A|#{d})\s*"(.*?)"\s*(#{d}\s*|\z)/) { tag_list << $2; $3 }
27
+ string.gsub!(/(\A|#{d})\s*'(.*?)'\s*(#{d}\s*|\z)/) { tag_list << $2; $3 }
28
+
29
+ tag_list.add(string.split(Regexp.new d))
30
+ end
31
+ end
32
+
33
+ ##
34
+ # Add tags to the tag_list. Duplicate or blank tags will be ignored.
35
+ # Use the <tt>:parse</tt> option to add an unparsed tag string.
36
+ #
37
+ # Example:
38
+ # tag_list.add("Fun", "Happy")
39
+ # tag_list.add("Fun, Happy", :parse => true)
40
+ def add(*names)
41
+ extract_and_apply_options!(names)
42
+ concat(names)
43
+ clean!
44
+ self
45
+ end
46
+
47
+ ##
48
+ # Remove specific tags from the tag_list.
49
+ # Use the <tt>:parse</tt> option to add an unparsed tag string.
50
+ #
51
+ # Example:
52
+ # tag_list.remove("Sad", "Lonely")
53
+ # tag_list.remove("Sad, Lonely", :parse => true)
54
+ def remove(*names)
55
+ extract_and_apply_options!(names)
56
+ delete_if { |name| names.include?(name) }
57
+ self
58
+ end
59
+
60
+ ##
61
+ # Transform the tag_list into a tag string suitable for editing in a form.
62
+ # The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
63
+ #
64
+ # Example:
65
+ # tag_list = TagList.new("Round", "Square,Cube")
66
+ # tag_list.to_s # 'Round, "Square,Cube"'
67
+ def to_s
68
+ tags = frozen? ? self.dup : self
69
+ tags.send(:clean!)
70
+
71
+ tags.map do |name|
72
+ d = ActsAsTaggableOn.delimiter
73
+ d = Regexp.new d.join("|") if d.kind_of? Array
74
+ name.index(d) ? "\"#{name}\"" : name
75
+ end.join(ActsAsTaggableOn.glue)
76
+ end
77
+
78
+ private
79
+
80
+ # Remove whitespace, duplicates, and blanks.
81
+ def clean!
82
+ reject!(&:blank?)
83
+ map!(&:strip)
84
+ map!{ |tag| tag.mb_chars.downcase.to_s } if ActsAsTaggableOn.force_lowercase
85
+ map!(&:parameterize) if ActsAsTaggableOn.force_parameterize
86
+
87
+ uniq!
88
+ end
89
+
90
+ def extract_and_apply_options!(args)
91
+ options = args.last.is_a?(Hash) ? args.pop : {}
92
+ options.assert_valid_keys :parse
93
+
94
+ if options[:parse]
95
+ args.map! { |a| self.class.from(a) }
96
+ end
97
+
98
+ args.flatten!
99
+ end
100
+ end
101
+ end