acts_as_taggable_on 3.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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