acts-as-taggable-on 2.0.6 → 2.3.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 (49) hide show
  1. data/.gitignore +10 -0
  2. data/.rspec +2 -0
  3. data/.travis.yml +9 -0
  4. data/CHANGELOG +10 -0
  5. data/Gemfile +2 -9
  6. data/Guardfile +5 -0
  7. data/README.rdoc +89 -66
  8. data/Rakefile +9 -55
  9. data/acts-as-taggable-on.gemspec +28 -0
  10. data/lib/acts-as-taggable-on/version.rb +4 -0
  11. data/lib/acts-as-taggable-on.rb +33 -4
  12. data/lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb +4 -4
  13. data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +38 -43
  14. data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +146 -38
  15. data/lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb +37 -0
  16. data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +36 -11
  17. data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +23 -15
  18. data/lib/acts_as_taggable_on/tag.rb +16 -13
  19. data/lib/acts_as_taggable_on/tag_list.rb +13 -12
  20. data/lib/acts_as_taggable_on/taggable.rb +102 -0
  21. data/lib/acts_as_taggable_on/{acts_as_tagger.rb → tagger.rb} +3 -3
  22. data/lib/acts_as_taggable_on/tagging.rb +12 -2
  23. data/lib/acts_as_taggable_on/tags_helper.rb +2 -2
  24. data/lib/acts_as_taggable_on/utils.rb +34 -0
  25. data/lib/generators/acts_as_taggable_on/migration/migration_generator.rb +9 -2
  26. data/lib/generators/acts_as_taggable_on/migration/templates/active_record/migration.rb +3 -1
  27. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +300 -54
  28. data/spec/acts_as_taggable_on/tag_list_spec.rb +84 -61
  29. data/spec/acts_as_taggable_on/tag_spec.rb +51 -13
  30. data/spec/acts_as_taggable_on/taggable_spec.rb +261 -34
  31. data/spec/acts_as_taggable_on/tagger_spec.rb +36 -15
  32. data/spec/acts_as_taggable_on/tagging_spec.rb +2 -5
  33. data/spec/acts_as_taggable_on/tags_helper_spec.rb +16 -0
  34. data/spec/acts_as_taggable_on/utils_spec.rb +21 -0
  35. data/spec/database.yml.sample +4 -2
  36. data/spec/generators/acts_as_taggable_on/migration/migration_generator_spec.rb +22 -0
  37. data/spec/models.rb +19 -1
  38. data/spec/schema.rb +18 -0
  39. data/spec/spec_helper.rb +30 -7
  40. data/uninstall.rb +1 -0
  41. metadata +137 -51
  42. data/VERSION +0 -1
  43. data/generators/acts_as_taggable_on_migration/acts_as_taggable_on_migration_generator.rb +0 -7
  44. data/generators/acts_as_taggable_on_migration/templates/migration.rb +0 -29
  45. data/lib/acts_as_taggable_on/acts_as_taggable_on.rb +0 -53
  46. data/lib/acts_as_taggable_on/compatibility/Gemfile +0 -8
  47. data/lib/acts_as_taggable_on/compatibility/active_record_backports.rb +0 -17
  48. data/lib/acts_as_taggable_on/compatibility/postgresql.rb +0 -44
  49. data/spec/database.yml +0 -17
@@ -8,21 +8,32 @@ module ActsAsTaggableOn::Taggable
8
8
  attr_writer :custom_contexts
9
9
  after_save :save_tags
10
10
  end
11
-
11
+
12
12
  base.initialize_acts_as_taggable_on_core
13
13
  end
14
-
14
+
15
15
  module ClassMethods
16
16
  def initialize_acts_as_taggable_on_core
17
17
  tag_types.map(&:to_s).each do |tags_type|
18
18
  tag_type = tags_type.to_s.singularize
19
19
  context_taggings = "#{tag_type}_taggings".to_sym
20
20
  context_tags = tags_type.to_sym
21
-
21
+ taggings_order = (preserve_tag_order? ? "#{ActsAsTaggableOn::Tagging.table_name}.id" : nil)
22
+
22
23
  class_eval do
23
- has_many context_taggings, :as => :taggable, :dependent => :destroy, :include => :tag, :class_name => "ActsAsTaggableOn::Tagging",
24
- :conditions => ["#{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", tags_type]
25
- has_many context_tags, :through => context_taggings, :source => :tag, :class_name => "ActsAsTaggableOn::Tag"
24
+ # when preserving tag order, include order option so that for a 'tags' context
25
+ # the associations tag_taggings & tags are always returned in created order
26
+ has_many context_taggings, :as => :taggable,
27
+ :dependent => :destroy,
28
+ :include => :tag,
29
+ :class_name => "ActsAsTaggableOn::Tagging",
30
+ :conditions => ["#{ActsAsTaggableOn::Tagging.table_name}.context = ?", tags_type],
31
+ :order => taggings_order
32
+
33
+ has_many context_tags, :through => context_taggings,
34
+ :source => :tag,
35
+ :class_name => "ActsAsTaggableOn::Tag",
36
+ :order => taggings_order
26
37
  end
27
38
 
28
39
  class_eval %(
@@ -38,11 +49,11 @@ module ActsAsTaggableOn::Taggable
38
49
  all_tags_list_on('#{tags_type}')
39
50
  end
40
51
  )
41
- end
52
+ end
42
53
  end
43
-
44
- def acts_as_taggable_on(*args)
45
- super(*args)
54
+
55
+ def taggable_on(preserve_tag_order, *tag_types)
56
+ super(preserve_tag_order, *tag_types)
46
57
  initialize_acts_as_taggable_on_core
47
58
  end
48
59
 
@@ -59,39 +70,72 @@ module ActsAsTaggableOn::Taggable
59
70
  # * <tt>:exclude</tt> - if set to true, return objects that are *NOT* tagged with the specified tags
60
71
  # * <tt>:any</tt> - if set to true, return objects that are tagged with *ANY* of the specified tags
61
72
  # * <tt>:match_all</tt> - if set to true, return objects that are *ONLY* tagged with the specified tags
73
+ # * <tt>:owned_by</tt> - return objects that are *ONLY* owned by the owner
62
74
  #
63
75
  # Example:
64
76
  # User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
65
77
  # User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
66
78
  # User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
67
79
  # User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
80
+ # User.tagged_with("awesome", "cool", :owned_by => foo ) # Users that are tagged with just awesome and cool by 'foo'
68
81
  def tagged_with(tags, options = {})
69
82
  tag_list = ActsAsTaggableOn::TagList.from(tags)
83
+ empty_result = scoped(:conditions => "1 = 0")
70
84
 
71
- return {} if tag_list.empty?
85
+ return empty_result if tag_list.empty?
72
86
 
73
87
  joins = []
74
88
  conditions = []
75
89
 
76
90
  context = options.delete(:on)
91
+ owned_by = options.delete(:owned_by)
92
+ alias_base_name = undecorated_table_name.gsub('.','_')
77
93
 
78
94
  if options.delete(:exclude)
79
- tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
80
- conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{ActsAsTaggableOn::Tagging.table_name}.taggable_id FROM #{ActsAsTaggableOn::Tagging.table_name} JOIN #{ActsAsTaggableOn::Tag.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
95
+ if options.delete(:wild)
96
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ? ESCAPE '!'", "%#{escape_like(t)}%"]) }.join(" OR ")
97
+ else
98
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ?", t]) }.join(" OR ")
99
+ end
100
+
101
+ conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{ActsAsTaggableOn::Tagging.table_name}.taggable_id FROM #{ActsAsTaggableOn::Tagging.table_name} JOIN #{ActsAsTaggableOn::Tag.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
81
102
 
82
103
  elsif options.delete(:any)
83
- tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
84
- conditions << "#{table_name}.#{primary_key} IN (SELECT #{ActsAsTaggableOn::Tagging.table_name}.taggable_id FROM #{ActsAsTaggableOn::Tagging.table_name} JOIN #{ActsAsTaggableOn::Tag.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
104
+ # get tags, drop out if nothing returned (we need at least one)
105
+ if options.delete(:wild)
106
+ tags = ActsAsTaggableOn::Tag.named_like_any(tag_list)
107
+ else
108
+ tags = ActsAsTaggableOn::Tag.named_any(tag_list)
109
+ end
110
+
111
+ return scoped(:conditions => "1 = 0") unless tags.length > 0
112
+
113
+ # setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123
114
+ # avoid ambiguous column name
115
+ taggings_context = context ? "_#{context}" : ''
116
+
117
+ taggings_alias = adjust_taggings_alias(
118
+ "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{sha_prefix(tags.map(&:name).join('_'))}"
119
+ )
120
+
121
+ tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
122
+ " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
123
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
124
+ tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
125
+
126
+ # don't need to sanitize sql, map all ids and join with OR logic
127
+ conditions << tags.map { |t| "#{taggings_alias}.tag_id = #{t.id}" }.join(" OR ")
128
+ select_clause = "DISTINCT #{table_name}.*" unless context and tag_types.one?
129
+
130
+ joins << tagging_join
85
131
 
86
132
  else
87
133
  tags = ActsAsTaggableOn::Tag.named_any(tag_list)
88
- return scoped(:conditions => "1 = 0") unless tags.length == tag_list.length
134
+ return empty_result unless tags.length == tag_list.length
89
135
 
90
136
  tags.each do |tag|
91
- safe_tag = tag.name.gsub(/[^a-zA-Z0-9]/, '')
92
- prefix = "#{safe_tag}_#{rand(1024)}"
93
137
 
94
- taggings_alias = "#{table_name}_taggings_#{prefix}"
138
+ taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{sha_prefix(tag.name)}")
95
139
 
96
140
  tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
97
141
  " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
@@ -99,22 +143,33 @@ module ActsAsTaggableOn::Taggable
99
143
  " AND #{taggings_alias}.tag_id = #{tag.id}"
100
144
  tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
101
145
 
146
+ if owned_by
147
+ tagging_join << " AND " +
148
+ sanitize_sql([
149
+ "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
150
+ owned_by.id,
151
+ owned_by.class.to_s
152
+ ])
153
+ end
154
+
102
155
  joins << tagging_join
103
156
  end
104
157
  end
105
158
 
106
- taggings_alias, tags_alias = "#{table_name}_taggings_group", "#{table_name}_tags_group"
159
+ taggings_alias, tags_alias = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group"
107
160
 
108
161
  if options.delete(:match_all)
109
162
  joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
110
163
  " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
111
164
  " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
112
165
 
113
- group = "#{grouped_column_names_for(self)} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
114
- end
115
166
 
167
+ group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
168
+ group = "#{group_columns} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
169
+ end
116
170
 
117
- scoped(:joins => joins.join(" "),
171
+ scoped(:select => select_clause,
172
+ :joins => joins.join(" "),
118
173
  :group => group,
119
174
  :conditions => conditions.join(" AND "),
120
175
  :order => options[:order],
@@ -124,8 +179,15 @@ module ActsAsTaggableOn::Taggable
124
179
  def is_taggable?
125
180
  true
126
181
  end
127
- end
128
-
182
+
183
+ def adjust_taggings_alias(taggings_alias)
184
+ if taggings_alias.size > 75
185
+ taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias)
186
+ end
187
+ taggings_alias
188
+ end
189
+ end
190
+
129
191
  module InstanceMethods
130
192
  # all column names are necessary for PostgreSQL group clause
131
193
  def grouped_column_names_for(object)
@@ -176,20 +238,35 @@ module ActsAsTaggableOn::Taggable
176
238
  tag_table_name = ActsAsTaggableOn::Tag.table_name
177
239
  tagging_table_name = ActsAsTaggableOn::Tagging.table_name
178
240
 
179
- opts = ["#{tagging_table_name}.context = ?", context.to_s]
180
- base_tags.where(opts).order("max(#{tagging_table_name}.created_at)").group("#{tag_table_name}.id, #{tag_table_name}.name").all
241
+ opts = ["#{tagging_table_name}.context = ?", context.to_s]
242
+ scope = base_tags.where(opts)
243
+
244
+ if ActsAsTaggableOn::Tag.using_postgresql?
245
+ group_columns = grouped_column_names_for(ActsAsTaggableOn::Tag)
246
+ scope = scope.order("max(#{tagging_table_name}.created_at)").group(group_columns)
247
+ else
248
+ scope = scope.group("#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}")
249
+ end
250
+
251
+ scope.all
181
252
  end
182
253
 
183
254
  ##
184
255
  # Returns all tags that are not owned of a given context
185
256
  def tags_on(context)
186
- base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s]).all
257
+ scope = base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s])
258
+ # when preserving tag order, return tags in created order
259
+ # if we added the order to the association this would always apply
260
+ scope = scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") if self.class.preserve_tag_order?
261
+ scope.all
187
262
  end
188
263
 
189
264
  def set_tag_list_on(context, new_list)
190
265
  add_custom_context(context)
191
266
 
192
267
  variable_name = "@#{context.to_s.singularize}_list"
268
+ process_dirty_object(context, new_list) unless custom_contexts.include?(context.to_s)
269
+
193
270
  instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(new_list))
194
271
  end
195
272
 
@@ -197,12 +274,26 @@ module ActsAsTaggableOn::Taggable
197
274
  custom_contexts + self.class.tag_types.map(&:to_s)
198
275
  end
199
276
 
277
+ def process_dirty_object(context,new_list)
278
+ value = new_list.is_a?(Array) ? new_list.join(', ') : new_list
279
+ attrib = "#{context.to_s.singularize}_list"
280
+
281
+ if changed_attributes.include?(attrib)
282
+ # The attribute already has an unsaved change.
283
+ old = changed_attributes[attrib]
284
+ changed_attributes.delete(attrib) if (old.to_s == value.to_s)
285
+ else
286
+ old = tag_list_on(context).to_s
287
+ changed_attributes[attrib] = old if (old.to_s != value.to_s)
288
+ end
289
+ end
290
+
200
291
  def reload(*args)
201
292
  self.class.tag_types.each do |context|
202
293
  instance_variable_set("@#{context.to_s.singularize}_list", nil)
203
294
  instance_variable_set("@all_#{context.to_s.singularize}_list", nil)
204
295
  end
205
-
296
+
206
297
  super(*args)
207
298
  end
208
299
 
@@ -210,22 +301,39 @@ module ActsAsTaggableOn::Taggable
210
301
  tagging_contexts.each do |context|
211
302
  next unless tag_list_cache_set_on(context)
212
303
 
304
+ # List of currently assigned tag names
213
305
  tag_list = tag_list_cache_on(context).uniq
214
306
 
215
307
  # Find existing tags or create non-existing tags:
216
- tag_list = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list)
308
+ tags = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list)
217
309
 
310
+ # Tag objects for currently assigned tags
218
311
  current_tags = tags_on(context)
219
- old_tags = current_tags - tag_list
220
- new_tags = tag_list - current_tags
221
-
312
+
313
+ # Tag maintenance based on whether preserving the created order of tags
314
+ if self.class.preserve_tag_order?
315
+ # First off order the array of tag objects to match the tag list
316
+ # rather than existing tags followed by new tags
317
+ tags = tag_list.map{|l| tags.detect{|t| t.name.downcase == l.downcase}}
318
+ # To preserve tags in the order in which they were added
319
+ # delete all current tags and create new tags if the content or order has changed
320
+ old_tags = (tags == current_tags ? [] : current_tags)
321
+ new_tags = (tags == current_tags ? [] : tags)
322
+ else
323
+ # Delete discarded tags and create new tags
324
+ old_tags = current_tags - tags
325
+ new_tags = tags - current_tags
326
+ end
327
+
222
328
  # Find taggings to remove:
223
- old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil,
224
- :context => context.to_s, :tag_id => old_tags).all
329
+ if old_tags.present?
330
+ old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil,
331
+ :context => context.to_s, :tag_id => old_tags).all
332
+ end
225
333
 
334
+ # Destroy old taggings:
226
335
  if old_taggings.present?
227
- # Destroy old taggings:
228
- ActsAsTaggableOn::Tagging.destroy_all :id => old_taggings.map(&:id)
336
+ ActsAsTaggableOn::Tagging.destroy_all "#{ActsAsTaggableOn::Tagging.primary_key}".to_sym => old_taggings.map(&:id)
229
337
  end
230
338
 
231
339
  # Create new taggings:
@@ -238,4 +346,4 @@ module ActsAsTaggableOn::Taggable
238
346
  end
239
347
  end
240
348
  end
241
- end
349
+ end
@@ -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 %(
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
+ )
32
+
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -30,9 +30,17 @@ module ActsAsTaggableOn::Taggable
30
30
 
31
31
  module InstanceMethods
32
32
  def owner_tags_on(owner, context)
33
- base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND
34
- #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = ? AND
35
- #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = ?), context.to_s, owner.id, owner.class.to_s]).all
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.to_s])
39
+ end
40
+ # when preserving tag order, return tags in created order
41
+ # if we added the order to the association this would always apply
42
+ scope = scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") if self.class.preserve_tag_order?
43
+ scope.all
36
44
  end
37
45
 
38
46
  def cached_owned_tag_list_on(context)
@@ -69,21 +77,38 @@ module ActsAsTaggableOn::Taggable
69
77
  def save_owned_tags
70
78
  tagging_contexts.each do |context|
71
79
  cached_owned_tag_list_on(context).each do |owner, tag_list|
80
+
72
81
  # Find existing tags or create non-existing tags:
73
- tag_list = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list.uniq)
82
+ tags = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list.uniq)
74
83
 
75
- owned_tags = owner_tags_on(owner, context)
76
- old_tags = owned_tags - tag_list
77
- new_tags = tag_list - owned_tags
84
+ # Tag objects for owned tags
85
+ owned_tags = owner_tags_on(owner, context)
86
+
87
+ # Tag maintenance based on whether preserving the created order of tags
88
+ if self.class.preserve_tag_order?
89
+ # First off order the array of tag objects to match the tag list
90
+ # rather than existing tags followed by new tags
91
+ tags = tag_list.uniq.map{|s| tags.detect{|t| t.name.downcase == s.downcase}}
92
+ # To preserve tags in the order in which they were added
93
+ # delete all owned tags and create new tags if the content or order has changed
94
+ old_tags = (tags == owned_tags ? [] : owned_tags)
95
+ new_tags = (tags == owned_tags ? [] : tags)
96
+ else
97
+ # Delete discarded tags and create new tags
98
+ old_tags = owned_tags - tags
99
+ new_tags = tags - owned_tags
100
+ end
78
101
 
79
102
  # Find all taggings that belong to the taggable (self), are owned by the owner,
80
103
  # have the correct context, and are removed from the list.
81
- old_taggings = ActsAsTaggableOn::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
104
+ if old_tags.present?
105
+ old_taggings = ActsAsTaggableOn::Tagging.where(:taggable_id => id, :taggable_type => self.class.base_class.to_s,
106
+ :tagger_type => owner.class.to_s, :tagger_id => owner.id,
107
+ :tag_id => old_tags, :context => context).all
108
+ end
84
109
 
110
+ # Destroy old taggings:
85
111
  if old_taggings.present?
86
- # Destroy old taggings:
87
112
  ActsAsTaggableOn::Tagging.destroy_all(:id => old_taggings.map(&:id))
88
113
  end
89
114
 
@@ -5,7 +5,7 @@ module ActsAsTaggableOn::Taggable
5
5
  base.extend ActsAsTaggableOn::Taggable::Related::ClassMethods
6
6
  base.initialize_acts_as_taggable_on_related
7
7
  end
8
-
8
+
9
9
  module ClassMethods
10
10
  def initialize_acts_as_taggable_on_related
11
11
  tag_types.map(&:to_s).each do |tag_type|
@@ -18,7 +18,11 @@ module ActsAsTaggableOn::Taggable
18
18
  def find_related_#{tag_type}_for(klass, options = {})
19
19
  related_tags_for('#{tag_type}', klass, options)
20
20
  end
21
+ )
22
+ end
21
23
 
24
+ unless tag_types.empty?
25
+ class_eval %(
22
26
  def find_matching_contexts(search_context, result_context, options = {})
23
27
  matching_contexts_for(search_context.to_s, result_context.to_s, self.class, options)
24
28
  end
@@ -27,39 +31,43 @@ module ActsAsTaggableOn::Taggable
27
31
  matching_contexts_for(search_context.to_s, result_context.to_s, klass, options)
28
32
  end
29
33
  )
30
- end
34
+ end
31
35
  end
32
-
36
+
33
37
  def acts_as_taggable_on(*args)
34
38
  super(*args)
35
39
  initialize_acts_as_taggable_on_related
36
40
  end
37
41
  end
38
-
42
+
39
43
  module InstanceMethods
40
44
  def matching_contexts_for(search_context, result_context, klass, options = {})
41
45
  tags_to_find = tags_on(search_context).collect { |t| t.name }
42
46
 
43
- exclude_self = "#{klass.table_name}.id != #{id} AND" if self.class == klass
44
-
45
- klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.id) AS count",
47
+ exclude_self = "#{klass.table_name}.#{klass.primary_key} != #{id} AND" if [self.class.base_class, self.class].include? klass
48
+
49
+ group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(klass) : "#{klass.table_name}.#{klass.primary_key}"
50
+
51
+ klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}) AS count",
46
52
  :from => "#{klass.table_name}, #{ActsAsTaggableOn::Tag.table_name}, #{ActsAsTaggableOn::Tagging.table_name}",
47
- :conditions => ["#{exclude_self} #{klass.table_name}.id = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = '#{klass.to_s}' AND #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id AND #{ActsAsTaggableOn::Tag.table_name}.name IN (?) AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", tags_to_find, result_context],
48
- :group => grouped_column_names_for(klass),
53
+ :conditions => ["#{exclude_self} #{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],
54
+ :group => group_columns,
49
55
  :order => "count DESC" }.update(options))
50
56
  end
51
-
57
+
52
58
  def related_tags_for(context, klass, options = {})
53
59
  tags_to_find = tags_on(context).collect { |t| t.name }
54
60
 
55
- exclude_self = "#{klass.table_name}.id != #{id} AND" if self.class == klass
61
+ exclude_self = "#{klass.table_name}.#{klass.primary_key} != #{id} AND" if [self.class.base_class, self.class].include? klass
62
+
63
+ group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(klass) : "#{klass.table_name}.#{klass.primary_key}"
56
64
 
57
- klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.id) AS count",
65
+ klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}) AS count",
58
66
  :from => "#{klass.table_name}, #{ActsAsTaggableOn::Tag.table_name}, #{ActsAsTaggableOn::Tagging.table_name}",
59
- :conditions => ["#{exclude_self} #{klass.table_name}.id = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = '#{klass.to_s}' AND #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id AND #{ActsAsTaggableOn::Tag.table_name}.name IN (?)", tags_to_find],
60
- :group => grouped_column_names_for(klass),
67
+ :conditions => ["#{exclude_self} #{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],
68
+ :group => group_columns,
61
69
  :order => "count DESC" }.update(options))
62
70
  end
63
71
  end
64
72
  end
65
- end
73
+ end
@@ -1,7 +1,7 @@
1
1
  module ActsAsTaggableOn
2
2
  class Tag < ::ActiveRecord::Base
3
- include ActsAsTaggableOn::ActiveRecord::Backports if ::ActiveRecord::VERSION::MAJOR < 3
4
-
3
+ include ActsAsTaggableOn::Utils
4
+
5
5
  attr_accessible :name
6
6
 
7
7
  ### ASSOCIATIONS:
@@ -12,23 +12,24 @@ module ActsAsTaggableOn
12
12
 
13
13
  validates_presence_of :name
14
14
  validates_uniqueness_of :name
15
+ validates_length_of :name, :maximum => 255
15
16
 
16
17
  ### SCOPES:
17
18
 
18
19
  def self.named(name)
19
- where(["name #{like_operator} ?", name])
20
+ where(["lower(name) = ?", name.downcase])
20
21
  end
21
-
22
+
22
23
  def self.named_any(list)
23
- where(list.map { |tag| sanitize_sql(["name #{like_operator} ?", tag.to_s]) }.join(" OR "))
24
+ where(list.map { |tag| sanitize_sql(["lower(name) = ?", tag.to_s.downcase]) }.join(" OR "))
24
25
  end
25
-
26
+
26
27
  def self.named_like(name)
27
- where(["name #{like_operator} ?", "%#{name}%"])
28
+ where(["name #{like_operator} ? ESCAPE '!'", "%#{escape_like(name)}%"])
28
29
  end
29
30
 
30
31
  def self.named_like_any(list)
31
- where(list.map { |tag| sanitize_sql(["name #{like_operator} ?", "%#{tag.to_s}%"]) }.join(" OR "))
32
+ where(list.map { |tag| sanitize_sql(["name #{like_operator} ? ESCAPE '!'", "%#{escape_like(tag.to_s)}%"]) }.join(" OR "))
32
33
  end
33
34
 
34
35
  ### CLASS METHODS:
@@ -43,7 +44,10 @@ module ActsAsTaggableOn
43
44
  return [] if list.empty?
44
45
 
45
46
  existing_tags = Tag.named_any(list).all
46
- new_tag_names = list.reject { |name| existing_tags.any? { |tag| tag.name.mb_chars.downcase == name.mb_chars.downcase } }
47
+ new_tag_names = list.reject do |name|
48
+ name = comparable_name(name)
49
+ existing_tags.any? { |tag| comparable_name(tag.name) == name }
50
+ end
47
51
  created_tags = new_tag_names.map { |name| Tag.create(:name => name) }
48
52
 
49
53
  existing_tags + created_tags
@@ -65,10 +69,9 @@ module ActsAsTaggableOn
65
69
 
66
70
  class << self
67
71
  private
68
- def like_operator
69
- connection.adapter_name == 'PostgreSQL' ? 'ILIKE' : 'LIKE'
72
+ def comparable_name(str)
73
+ str.mb_chars.downcase.to_s
70
74
  end
71
75
  end
72
-
73
76
  end
74
- end
77
+ end
@@ -1,14 +1,13 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+
1
3
  module ActsAsTaggableOn
2
4
  class TagList < Array
3
- cattr_accessor :delimiter
4
- self.delimiter = ','
5
-
6
5
  attr_accessor :owner
7
6
 
8
7
  def initialize(*args)
9
8
  add(*args)
10
9
  end
11
-
10
+
12
11
  ##
13
12
  # Returns a new TagList using the given tag string.
14
13
  #
@@ -16,17 +15,16 @@ module ActsAsTaggableOn
16
15
  # tag_list = TagList.from("One , Two, Three")
17
16
  # tag_list # ["One", "Two", "Three"]
18
17
  def self.from(string)
19
- glue = delimiter.ends_with?(" ") ? delimiter : "#{delimiter} "
20
- string = string.join(glue) if string.respond_to?(:join)
18
+ string = string.join(ActsAsTaggableOn.glue) if string.respond_to?(:join)
21
19
 
22
20
  new.tap do |tag_list|
23
21
  string = string.to_s.dup
24
22
 
25
23
  # Parse the quoted tags
26
- string.gsub!(/(\A|#{delimiter})\s*"(.*?)"\s*(#{delimiter}\s*|\z)/) { tag_list << $2; $3 }
27
- string.gsub!(/(\A|#{delimiter})\s*'(.*?)'\s*(#{delimiter}\s*|\z)/) { tag_list << $2; $3 }
24
+ string.gsub!(/(\A|#{ActsAsTaggableOn.delimiter})\s*"(.*?)"\s*(#{ActsAsTaggableOn.delimiter}\s*|\z)/) { tag_list << $2; $3 }
25
+ string.gsub!(/(\A|#{ActsAsTaggableOn.delimiter})\s*'(.*?)'\s*(#{ActsAsTaggableOn.delimiter}\s*|\z)/) { tag_list << $2; $3 }
28
26
 
29
- tag_list.add(string.split(delimiter))
27
+ tag_list.add(string.split(ActsAsTaggableOn.delimiter))
30
28
  end
31
29
  end
32
30
 
@@ -69,16 +67,19 @@ module ActsAsTaggableOn
69
67
  tags.send(:clean!)
70
68
 
71
69
  tags.map do |name|
72
- name.include?(delimiter) ? "\"#{name}\"" : name
73
- end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
70
+ name.include?(ActsAsTaggableOn.delimiter) ? "\"#{name}\"" : name
71
+ end.join(ActsAsTaggableOn.glue)
74
72
  end
75
73
 
76
74
  private
77
-
75
+
78
76
  # Remove whitespace, duplicates, and blanks.
79
77
  def clean!
80
78
  reject!(&:blank?)
81
79
  map!(&:strip)
80
+ map!(&:downcase) if ActsAsTaggableOn.force_lowercase
81
+ map!(&:parameterize) if ActsAsTaggableOn.force_parameterize
82
+
82
83
  uniq!
83
84
  end
84
85