acts-as-taggable-on 1.0.13 → 2.0.0

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 (40) hide show
  1. data/CHANGELOG +5 -2
  2. data/Gemfile +6 -0
  3. data/README.rdoc +61 -31
  4. data/Rakefile +46 -16
  5. data/VERSION +1 -1
  6. data/generators/acts_as_taggable_on_migration/acts_as_taggable_on_migration_generator.rb +7 -0
  7. data/generators/acts_as_taggable_on_migration/templates/migration.rb +29 -0
  8. data/lib/acts-as-taggable-on.rb +30 -7
  9. data/lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb +53 -0
  10. data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +98 -0
  11. data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +237 -0
  12. data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +101 -0
  13. data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +64 -0
  14. data/lib/acts_as_taggable_on/acts_as_taggable_on.rb +43 -373
  15. data/lib/acts_as_taggable_on/acts_as_tagger.rb +58 -43
  16. data/lib/acts_as_taggable_on/compatibility/Gemfile +6 -0
  17. data/lib/acts_as_taggable_on/compatibility/active_record_backports.rb +17 -0
  18. data/lib/acts_as_taggable_on/tag.rb +47 -8
  19. data/lib/acts_as_taggable_on/tag_list.rb +45 -45
  20. data/lib/acts_as_taggable_on/tagging.rb +17 -2
  21. data/lib/acts_as_taggable_on/tags_helper.rb +8 -2
  22. data/lib/generators/acts_as_taggable_on/migration/migration_generator.rb +31 -0
  23. data/lib/generators/acts_as_taggable_on/migration/templates/active_record/migration.rb +28 -0
  24. data/rails/init.rb +1 -7
  25. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +98 -53
  26. data/spec/acts_as_taggable_on/acts_as_tagger_spec.rb +46 -4
  27. data/spec/acts_as_taggable_on/tag_list_spec.rb +18 -0
  28. data/spec/acts_as_taggable_on/tag_spec.rb +66 -13
  29. data/spec/acts_as_taggable_on/taggable_spec.rb +142 -70
  30. data/spec/acts_as_taggable_on/tagger_spec.rb +73 -5
  31. data/spec/acts_as_taggable_on/tagging_spec.rb +18 -3
  32. data/spec/acts_as_taggable_on/tags_helper_spec.rb +1 -3
  33. data/spec/bm.rb +52 -0
  34. data/spec/models.rb +30 -0
  35. data/spec/schema.rb +13 -2
  36. data/spec/spec.opts +1 -2
  37. data/spec/spec_helper.rb +39 -34
  38. metadata +28 -8
  39. data/lib/acts_as_taggable_on/group_helper.rb +0 -12
  40. data/spec/acts_as_taggable_on/group_helper_spec.rb +0 -18
@@ -0,0 +1,237 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Core
3
+ def self.included(base)
4
+ base.send :include, ActsAsTaggableOn::Taggable::Core::InstanceMethods
5
+ base.extend ActsAsTaggableOn::Taggable::Core::ClassMethods
6
+
7
+ base.class_eval do
8
+ attr_writer :custom_contexts
9
+ after_save :save_tags
10
+ end
11
+
12
+ base.initialize_acts_as_taggable_on_core
13
+ end
14
+
15
+ module ClassMethods
16
+ def initialize_acts_as_taggable_on_core
17
+ tag_types.map(&:to_s).each do |tags_type|
18
+ tag_type = tags_type.to_s.singularize
19
+ context_taggings = "#{tag_type}_taggings".to_sym
20
+ context_tags = tags_type.to_sym
21
+
22
+ class_eval do
23
+ has_many context_taggings, :as => :taggable, :dependent => :destroy, :include => :tag, :class_name => "Tagging",
24
+ :conditions => ['#{Tagging.table_name}.tagger_id IS NULL AND #{Tagging.table_name}.context = ?', tags_type]
25
+ has_many context_tags, :through => context_taggings, :source => :tag
26
+ end
27
+
28
+ class_eval %(
29
+ def #{tag_type}_list
30
+ tag_list_on('#{tags_type}')
31
+ end
32
+
33
+ def #{tag_type}_list=(new_tags)
34
+ set_tag_list_on('#{tags_type}', new_tags)
35
+ end
36
+
37
+ def all_#{tags_type}_list
38
+ all_tags_list_on('#{tags_type}')
39
+ end
40
+ )
41
+ end
42
+ end
43
+
44
+ def acts_as_taggable_on(*args)
45
+ super(*args)
46
+ initialize_acts_as_taggable_on_core
47
+ end
48
+
49
+ # all column names are necessary for PostgreSQL group clause
50
+ def grouped_column_names_for(object)
51
+ object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
52
+ end
53
+
54
+ ##
55
+ # Return a scope of objects that are tagged with the specified tags.
56
+ #
57
+ # @param tags The tags that we want to query for
58
+ # @param [Hash] options A hash of options to alter you query:
59
+ # * <tt>:exclude</tt> - if set to true, return objects that are *NOT* tagged with the specified tags
60
+ # * <tt>:any</tt> - if set to true, return objects that are tagged with *ANY* of the specified tags
61
+ # * <tt>:match_all</tt> - if set to true, return objects that are *ONLY* tagged with the specified tags
62
+ #
63
+ # Example:
64
+ # User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
65
+ # User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
66
+ # User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
67
+ # User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
68
+ def tagged_with(tags, options = {})
69
+ tag_list = TagList.from(tags)
70
+
71
+ return {} if tag_list.empty?
72
+
73
+ joins = []
74
+ conditions = []
75
+
76
+ context = options.delete(:on)
77
+
78
+ if options.delete(:exclude)
79
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
80
+ conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND (#{tags_conditions}) WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
81
+
82
+ elsif options.delete(:any)
83
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
84
+ conditions << "#{table_name}.#{primary_key} IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND (#{tags_conditions}) WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
85
+
86
+ else
87
+ tags = Tag.named_any(tag_list)
88
+ return where("1 = 0") unless tags.length == tag_list.length
89
+
90
+ tags.each do |tag|
91
+ safe_tag = tag.name.gsub(/[^a-zA-Z0-9]/, '')
92
+ prefix = "#{safe_tag}_#{rand(1024)}"
93
+
94
+ taggings_alias = "#{table_name}_taggings_#{prefix}"
95
+
96
+ tagging_join = "JOIN #{Tagging.table_name} #{taggings_alias}" +
97
+ " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
98
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
99
+ " AND #{taggings_alias}.tag_id = #{tag.id}"
100
+ tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
101
+
102
+ joins << tagging_join
103
+ end
104
+ end
105
+
106
+ taggings_alias, tags_alias = "#{table_name}_taggings_group", "#{table_name}_tags_group"
107
+
108
+ if options.delete(:match_all)
109
+ joins << "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias}" +
110
+ " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
111
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
112
+
113
+ group = "#{grouped_column_names_for(self)} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
114
+ end
115
+
116
+
117
+ scoped(:joins => joins.join(" "),
118
+ :group => group,
119
+ :conditions => conditions.join(" AND "),
120
+ :readonly => false)
121
+ end
122
+
123
+ def is_taggable?
124
+ true
125
+ end
126
+ end
127
+
128
+ module InstanceMethods
129
+ # all column names are necessary for PostgreSQL group clause
130
+ def grouped_column_names_for(object)
131
+ self.class.grouped_column_names_for(object)
132
+ end
133
+
134
+ def custom_contexts
135
+ @custom_contexts ||= []
136
+ end
137
+
138
+ def is_taggable?
139
+ self.class.is_taggable?
140
+ end
141
+
142
+ def add_custom_context(value)
143
+ custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s)
144
+ end
145
+
146
+ def cached_tag_list_on(context)
147
+ self["cached_#{context.to_s.singularize}_list"]
148
+ end
149
+
150
+ def tag_list_cache_set_on(context)
151
+ variable_name = "@#{context.to_s.singularize}_list"
152
+ !instance_variable_get(variable_name).nil?
153
+ end
154
+
155
+ def tag_list_cache_on(context)
156
+ variable_name = "@#{context.to_s.singularize}_list"
157
+ instance_variable_get(variable_name) || instance_variable_set(variable_name, TagList.new(tags_on(context).map(&:name)))
158
+ end
159
+
160
+ def tag_list_on(context)
161
+ add_custom_context(context)
162
+ tag_list_cache_on(context)
163
+ end
164
+
165
+ def all_tags_list_on(context)
166
+ variable_name = "@all_#{context.to_s.singularize}_list"
167
+ return instance_variable_get(variable_name) if instance_variable_get(variable_name)
168
+
169
+ instance_variable_set(variable_name, TagList.new(all_tags_on(context).map(&:name)).freeze)
170
+ end
171
+
172
+ ##
173
+ # Returns all tags of a given context
174
+ def all_tags_on(context)
175
+ opts = ["#{Tagging.table_name}.context = ?", context.to_s]
176
+ base_tags.where(opts).order("#{Tagging.table_name}.created_at").group("#{Tagging.table_name}.tag_id").all
177
+ end
178
+
179
+ ##
180
+ # Returns all tags that are not owned of a given context
181
+ def tags_on(context)
182
+ base_tags.where(["#{Tagging.table_name}.context = ? AND #{Tagging.table_name}.tagger_id IS NULL", context.to_s]).all
183
+ end
184
+
185
+ def set_tag_list_on(context, new_list)
186
+ add_custom_context(context)
187
+
188
+ variable_name = "@#{context.to_s.singularize}_list"
189
+ instance_variable_set(variable_name, TagList.from(new_list))
190
+ end
191
+
192
+ def tagging_contexts
193
+ custom_contexts + self.class.tag_types.map(&:to_s)
194
+ end
195
+
196
+ def reload
197
+ self.class.tag_types.each do |context|
198
+ instance_variable_set("@#{context.to_s.singularize}_list", nil)
199
+ instance_variable_set("@all_#{context.to_s.singularize}_list", nil)
200
+ end
201
+
202
+ super
203
+ end
204
+
205
+ def save_tags
206
+ tagging_contexts.each do |context|
207
+ next unless tag_list_cache_set_on(context)
208
+
209
+ tag_list = tag_list_cache_on(context).uniq
210
+
211
+ # Find existing tags or create non-existing tags:
212
+ tag_list = Tag.find_or_create_all_with_like_by_name(tag_list)
213
+
214
+ current_tags = tags_on(context)
215
+ old_tags = current_tags - tag_list
216
+ new_tags = tag_list - current_tags
217
+
218
+ # Find taggings to remove:
219
+ old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil,
220
+ :context => context.to_s, :tag_id => old_tags).all
221
+
222
+ if old_taggings.present?
223
+ # Destroy old taggings:
224
+ Tagging.destroy_all :id => old_taggings.map(&:id)
225
+ end
226
+
227
+ # Create new taggings:
228
+ new_tags.each do |tag|
229
+ taggings.create!(:tag_id => tag.id, :context => context.to_s, :taggable => self)
230
+ end
231
+ end
232
+
233
+ true
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,101 @@
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 %(
23
+ def #{tag_type}_from(owner)
24
+ owner_tag_list_on(owner, '#{tag_type}')
25
+ end
26
+ )
27
+ end
28
+ end
29
+ end
30
+
31
+ module InstanceMethods
32
+ def owner_tags_on(owner, context)
33
+ base_tags.where([%(#{Tagging.table_name}.context = ? AND
34
+ #{Tagging.table_name}.tagger_id = ? AND
35
+ #{Tagging.table_name}.tagger_type = ?), context.to_s, owner.id, owner.class.to_s]).all
36
+ end
37
+
38
+ def cached_owned_tag_list_on(context)
39
+ variable_name = "@owned_#{context}_list"
40
+ cache = instance_variable_get(variable_name) || instance_variable_set(variable_name, {})
41
+ end
42
+
43
+ def owner_tag_list_on(owner, context)
44
+ add_custom_context(context)
45
+
46
+ cache = cached_owned_tag_list_on(context)
47
+ cache.delete_if { |key, value| key.id == owner.id && key.class == owner.class }
48
+
49
+ cache[owner] ||= TagList.new(*owner_tags_on(owner, context).map(&:name))
50
+ end
51
+
52
+ def set_owner_tag_list_on(owner, context, new_list)
53
+ add_custom_context(context)
54
+
55
+ cache = cached_owned_tag_list_on(context)
56
+ cache.delete_if { |key, value| key.id == owner.id && key.class == owner.class }
57
+
58
+ cache[owner] = TagList.from(new_list)
59
+ end
60
+
61
+ def reload
62
+ self.class.tag_types.each do |context|
63
+ instance_variable_set("@owned_#{context}_list", nil)
64
+ end
65
+
66
+ super
67
+ end
68
+
69
+ def save_owned_tags
70
+ tagging_contexts.each do |context|
71
+ cached_owned_tag_list_on(context).each do |owner, tag_list|
72
+ # Find existing tags or create non-existing tags:
73
+ tag_list = Tag.find_or_create_all_with_like_by_name(tag_list.uniq)
74
+
75
+ owned_tags = owner_tags_on(owner, context)
76
+ old_tags = owned_tags - tag_list
77
+ new_tags = tag_list - owned_tags
78
+
79
+ # Find all taggings that belong to the taggable (self), are owned by the owner,
80
+ # have the correct context, and are removed from the list.
81
+ old_taggings = Tagging.where(:taggable_id => id, :taggable_type => self.class.base_class.to_s,
82
+ :tagger_type => owner.class.to_s, :tagger_id => owner.id,
83
+ :tag_id => old_tags, :context => context).all
84
+
85
+ if old_taggings.present?
86
+ # Destroy old taggings:
87
+ Tagging.destroy_all(:id => old_taggings.map(&:id))
88
+ end
89
+
90
+ # Create new taggings:
91
+ new_tags.each do |tag|
92
+ taggings.create!(:tag_id => tag.id, :context => context.to_s, :tagger => owner, :taggable => self)
93
+ end
94
+ end
95
+ end
96
+
97
+ true
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,64 @@
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
+ end
7
+
8
+ module ClassMethods
9
+ def initialize_acts_as_taggable_on_related
10
+ tag_types.map(&:to_s).each do |tag_type|
11
+ class_eval %(
12
+ def find_related_#{tag_type}(options = {})
13
+ related_tags_for('#{tag_type}', self.class, options)
14
+ end
15
+ alias_method :find_related_on_#{tag_type}, :find_related_#{tag_type}
16
+
17
+ def find_related_#{tag_type}_for(klass, options = {})
18
+ related_tags_for('#{tag_type}', klass, options)
19
+ end
20
+
21
+ def find_matching_contexts(search_context, result_context, options = {})
22
+ matching_contexts_for(search_context.to_s, result_context.to_s, self.class, options)
23
+ end
24
+
25
+ def find_matching_contexts_for(klass, search_context, result_context, options = {})
26
+ matching_contexts_for(search_context.to_s, result_context.to_s, klass, options)
27
+ end
28
+ )
29
+ end
30
+ end
31
+
32
+ def acts_as_taggable_on(*args)
33
+ super(*args)
34
+ initialize_acts_as_taggable_on_related
35
+ end
36
+ end
37
+
38
+ module InstanceMethods
39
+ def matching_contexts_for(search_context, result_context, klass, options = {})
40
+ tags_to_find = tags_on(search_context).collect { |t| t.name }
41
+
42
+ exclude_self = "#{klass.table_name}.id != #{id} AND" if self.class == klass
43
+
44
+ klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{Tag.table_name}.id) AS count",
45
+ :from => "#{klass.table_name}, #{Tag.table_name}, #{Tagging.table_name}",
46
+ :conditions => ["#{exclude_self} #{klass.table_name}.id = #{Tagging.table_name}.taggable_id AND #{Tagging.table_name}.taggable_type = '#{klass.to_s}' AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND #{Tag.table_name}.name IN (?) AND #{Tagging.table_name}.context = ?", tags_to_find, result_context],
47
+ :group => grouped_column_names_for(klass),
48
+ :order => "count DESC" }.update(options))
49
+ end
50
+
51
+ def related_tags_for(context, klass, options = {})
52
+ tags_to_find = tags_on(context).collect { |t| t.name }
53
+
54
+ exclude_self = "#{klass.table_name}.id != #{id} AND" if self.class == klass
55
+
56
+ klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{Tag.table_name}.id) AS count",
57
+ :from => "#{klass.table_name}, #{Tag.table_name}, #{Tagging.table_name}",
58
+ :conditions => ["#{exclude_self} #{klass.table_name}.id = #{Tagging.table_name}.taggable_id AND #{Tagging.table_name}.taggable_type = '#{klass.to_s}' AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND #{Tag.table_name}.name IN (?)", tags_to_find],
59
+ :group => grouped_column_names_for(klass),
60
+ :order => "count DESC" }.update(options))
61
+ end
62
+ end
63
+ end
64
+ end