acts-as-taggable-on 2.0.6 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
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