acts-as-taggable-on 2.2.0 → 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 (30) hide show
  1. data/.gitignore +3 -1
  2. data/README.rdoc +59 -7
  3. data/acts-as-taggable-on.gemspec +3 -3
  4. data/lib/acts-as-taggable-on/version.rb +1 -1
  5. data/lib/acts-as-taggable-on.rb +30 -3
  6. data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +7 -19
  7. data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +86 -21
  8. data/lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb +37 -0
  9. data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +33 -12
  10. data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +1 -1
  11. data/lib/acts_as_taggable_on/tag.rb +5 -8
  12. data/lib/acts_as_taggable_on/tag_list.rb +13 -12
  13. data/lib/acts_as_taggable_on/taggable.rb +102 -0
  14. data/lib/acts_as_taggable_on/{acts_as_tagger.rb → tagger.rb} +3 -3
  15. data/lib/acts_as_taggable_on/tagging.rb +12 -0
  16. data/lib/acts_as_taggable_on/tags_helper.rb +2 -2
  17. data/lib/acts_as_taggable_on/utils.rb +4 -0
  18. data/lib/generators/acts_as_taggable_on/migration/templates/active_record/migration.rb +3 -1
  19. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +58 -0
  20. data/spec/acts_as_taggable_on/tag_list_spec.rb +84 -65
  21. data/spec/acts_as_taggable_on/tag_spec.rb +32 -10
  22. data/spec/acts_as_taggable_on/taggable_spec.rb +147 -14
  23. data/spec/acts_as_taggable_on/tagger_spec.rb +23 -16
  24. data/spec/acts_as_taggable_on/tagging_spec.rb +2 -5
  25. data/spec/acts_as_taggable_on/tags_helper_spec.rb +16 -0
  26. data/spec/models.rb +5 -0
  27. data/spec/schema.rb +5 -0
  28. data/spec/spec_helper.rb +3 -1
  29. metadata +26 -25
  30. data/lib/acts_as_taggable_on/acts_as_taggable_on.rb +0 -54
data/.gitignore CHANGED
@@ -5,4 +5,6 @@
5
5
  .rvmrc
6
6
  Gemfile.lock
7
7
  spec/database.yml
8
- tmp
8
+ tmp*.sw?
9
+ *.sw?
10
+ tmp
data/README.rdoc CHANGED
@@ -16,17 +16,15 @@ was used.
16
16
 
17
17
  == Installation
18
18
 
19
- === Rails 2.3.x
19
+ === Rails 2.x
20
20
 
21
- To use it, add it to your Gemfile:
22
-
23
- gem 'acts-as-taggable-on', '2.1.0'
21
+ Not supported any more! It is time for update guys.
24
22
 
25
23
  === Rails 3.x
26
24
 
27
25
  To use it, add it to your Gemfile:
28
26
 
29
- gem 'acts-as-taggable-on'
27
+ gem 'acts-as-taggable-on', '~> 2.2.2'
30
28
 
31
29
  ==== Post Installation
32
30
 
@@ -61,6 +59,25 @@ directory, you can run the specs for RoR 3.x with:
61
59
  User.skill_counts # => [<Tag name="joking" count=2>,<Tag name="clowning" count=1>...]
62
60
  @frankie.skill_counts
63
61
 
62
+ To preserve the order in which tags are created use acts_as_ordered_taggable:
63
+
64
+ class User < ActiveRecord::Base
65
+ # Alias for <tt>acts_as_ordered_taggable_on :tags</tt>:
66
+ acts_as_ordered_taggable
67
+ acts_as_ordered_taggable_on :skills, :interests
68
+ end
69
+
70
+ @user = User.new(:name => "Bobby")
71
+ @user.tag_list = "east, south"
72
+ @user.save
73
+
74
+ @user.tag_list = "north, east, south, west"
75
+ @user.save
76
+
77
+ @user.reload
78
+ @user.tag_list # => ["north", "east", "south", "west"]
79
+
80
+
64
81
  === Finding Tagged Objects
65
82
 
66
83
  Acts As Taggable On utilizes named_scopes to create an association for tags.
@@ -76,15 +93,20 @@ compatibility with the will_paginate gem:
76
93
  User.tagged_with("awesome").by_date.paginate(:page => params[:page], :per_page => 20)
77
94
 
78
95
  # Find a user with matching all tags, not just one
79
- User.tagged_with(["awesome", "cool"], :match_all => :true)
96
+ User.tagged_with(["awesome", "cool"], :match_all => true)
80
97
 
81
98
  # Find a user with any of the tags:
82
99
  User.tagged_with(["awesome", "cool"], :any => true)
83
100
 
101
+ # Find a user that not tags with awesome or cool:
102
+ User.tagged_with(["awesome", "cool"], :exclude => true)
103
+
84
104
  # Find a user with any of tags based on context:
85
105
  User.tagged_with(['awesome, cool'], :on => :tags, :any => true).tagged_with(['smart', 'shy'], :on => :skills, :any => true)
86
106
 
87
- Note: User.tagged_with([]) or '' will return [], but not all records.
107
+ You can also use :wild => true option along with :any or :exclude option. It will looking for %awesome% and %cool% in sql.
108
+
109
+ Tip: User.tagged_with([]) or '' will return [], but not all records.
88
110
 
89
111
  === Relationships
90
112
 
@@ -136,6 +158,21 @@ Tags can have owners:
136
158
  @some_photo.locations_from(@some_user) # => ["paris", "normandy"]
137
159
  @some_photo.owner_tags_on(@some_user, :locations) # => [#<ActsAsTaggableOn::Tag id: 1, name: "paris">...]
138
160
  @some_photo.owner_tags_on(nil, :locations) # => Ownerships equivalent to saying @some_photo.locations
161
+ @some_user.tag(@some_photo, :with => "paris, normandy", :on => :locations, :skip_save => true) #won't save @some_photo object
162
+
163
+ === Dirty objects
164
+
165
+ @bobby = User.find_by_name("Bobby")
166
+ @bobby.skill_list # => ["jogging", "diving"]
167
+
168
+ @boddy.skill_list_changed? #=> false
169
+ @boddy.changes #=> {}
170
+
171
+ @bobby.skill_list = "swimming"
172
+ @bobby.changes.should == {"skill_list"=>["jogging, diving", ["swimming"]]}
173
+ @boddy.skill_list_changed? #=> true
174
+
175
+ @bobby.skill_list_change.should == ["jogging, diving", ["swimming"]]
139
176
 
140
177
  === Tag cloud calculations
141
178
 
@@ -177,6 +214,21 @@ CSS:
177
214
  .css3 { font-size: 1.4em; }
178
215
  .css4 { font-size: 1.6em; }
179
216
 
217
+ == Configuration
218
+
219
+ If you would like to remove unused tag objects after removing taggings, add
220
+
221
+ ActsAsTaggableOn.remove_unused_tags = true
222
+
223
+ If you want force tags to be saved downcased:
224
+
225
+ ActsAsTaggableOn.force_lowercase = true
226
+
227
+ If you want tags to be saved parametrized (you can redefine to_param as well):
228
+
229
+ ActsAsTaggableOn.force_parameterize = true
230
+
231
+
180
232
  == Contributors
181
233
 
182
234
  We have a long list of valued contributors. {Check them all}[https://github.com/mbleigh/acts-as-taggable-on/contributors]
@@ -4,14 +4,14 @@ require 'acts-as-taggable-on/version'
4
4
  Gem::Specification.new do |gem|
5
5
  gem.name = %q{acts-as-taggable-on}
6
6
  gem.authors = ["Michael Bleigh"]
7
- gem.date = %q{2011-12-09}
7
+ gem.date = %q{2012-01-06}
8
8
  gem.description = %q{With ActsAsTaggableOn, you can tag a single model on several contexts, such as skills, interests, and awards. It also provides other advanced functionality.}
9
9
  gem.summary = "Advanced tagging for Rails."
10
10
  gem.email = %q{michael@intridea.com}
11
11
  gem.homepage = ''
12
12
 
13
- gem.add_runtime_dependency 'rails', '~> 3.1'
14
- gem.add_development_dependency 'rspec', '~> 2.5'
13
+ gem.add_runtime_dependency 'rails', '~> 3.0'
14
+ gem.add_development_dependency 'rspec', '~> 2.6'
15
15
  gem.add_development_dependency 'ammeter', '~> 0.1.3'
16
16
  gem.add_development_dependency 'sqlite3'
17
17
  gem.add_development_dependency 'mysql2', '~> 0.3.7'
@@ -1,4 +1,4 @@
1
1
  module ActsAsTaggableOn
2
- VERSION = '2.2.0'
2
+ VERSION = '2.3.0'
3
3
  end
4
4
 
@@ -2,18 +2,44 @@ require "active_record"
2
2
  require "active_record/version"
3
3
  require "action_view"
4
4
 
5
+ require "digest/sha1"
6
+
5
7
  $LOAD_PATH.unshift(File.dirname(__FILE__))
6
8
 
9
+ module ActsAsTaggableOn
10
+ mattr_accessor :delimiter
11
+ @@delimiter = ','
12
+
13
+ mattr_accessor :force_lowercase
14
+ @@force_lowercase = false
15
+
16
+ mattr_accessor :force_parameterize
17
+ @@force_parameterize = false
18
+
19
+ mattr_accessor :remove_unused_tags
20
+ self.remove_unused_tags = false
21
+
22
+ def self.glue
23
+ @@delimiter.ends_with?(" ") ? @@delimiter : "#{@@delimiter} "
24
+ end
25
+
26
+ def self.setup
27
+ yield self
28
+ end
29
+ end
30
+
31
+
7
32
  require "acts_as_taggable_on/utils"
8
33
 
9
- require "acts_as_taggable_on/acts_as_taggable_on"
34
+ require "acts_as_taggable_on/taggable"
10
35
  require "acts_as_taggable_on/acts_as_taggable_on/core"
11
36
  require "acts_as_taggable_on/acts_as_taggable_on/collection"
12
37
  require "acts_as_taggable_on/acts_as_taggable_on/cache"
13
38
  require "acts_as_taggable_on/acts_as_taggable_on/ownership"
14
39
  require "acts_as_taggable_on/acts_as_taggable_on/related"
40
+ require "acts_as_taggable_on/acts_as_taggable_on/dirty"
15
41
 
16
- require "acts_as_taggable_on/acts_as_tagger"
42
+ require "acts_as_taggable_on/tagger"
17
43
  require "acts_as_taggable_on/tag"
18
44
  require "acts_as_taggable_on/tag_list"
19
45
  require "acts_as_taggable_on/tags_helper"
@@ -29,4 +55,5 @@ end
29
55
 
30
56
  if defined?(ActionView::Base)
31
57
  ActionView::Base.send :include, ActsAsTaggableOn::TagsHelper
32
- end
58
+ end
59
+
@@ -53,11 +53,7 @@ module ActsAsTaggableOn::Taggable
53
53
  def all_tag_counts(options = {})
54
54
  options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on, :id
55
55
 
56
- scope = if ActiveRecord::VERSION::MAJOR >= 3
57
- {}
58
- else
59
- scope(:find) || {}
60
- end
56
+ scope = {}
61
57
 
62
58
  ## Generate conditions:
63
59
  options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
@@ -92,8 +88,6 @@ module ActsAsTaggableOn::Taggable
92
88
  tag_joins = [
93
89
  ].compact
94
90
 
95
- [tagging_joins, tag_joins].each(&:reverse!) if ActiveRecord::VERSION::MAJOR < 3
96
-
97
91
  ## Generate scope:
98
92
  tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id, COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) AS tags_count")
99
93
  tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*, #{ActsAsTaggableOn::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
@@ -112,18 +106,12 @@ module ActsAsTaggableOn::Taggable
112
106
 
113
107
  group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id"
114
108
 
115
- if ActiveRecord::VERSION::MAJOR >= 3
116
- # Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
117
- scoped_select = "#{table_name}.#{primary_key}"
118
- tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{select(scoped_select).to_sql})").
119
- group(group_columns).
120
- having(having)
121
- else
122
- # Having is not available in 2.3.x:
123
- group_by = "#{group_columns} HAVING COUNT(*) > 0"
124
- group_by << " AND #{having}" unless having.blank?
125
- tagging_scope = tagging_scope.group(group_by)
126
- end
109
+ # Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
110
+ scoped_select = "#{table_name}.#{primary_key}"
111
+ tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{select(scoped_select).to_sql})").
112
+ group(group_columns).
113
+ having(having)
114
+
127
115
 
128
116
  tag_scope = tag_scope.joins("JOIN (#{tagging_scope.to_sql}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id")
129
117
  tag_scope
@@ -18,11 +18,22 @@ module ActsAsTaggableOn::Taggable
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}.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 %(
@@ -41,11 +52,11 @@ module ActsAsTaggableOn::Taggable
41
52
  end
42
53
  end
43
54
 
44
- def acts_as_taggable_on(*args)
45
- super(*args)
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
+
49
60
  # all column names are necessary for PostgreSQL group clause
50
61
  def grouped_column_names_for(object)
51
62
  object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
@@ -81,20 +92,31 @@ module ActsAsTaggableOn::Taggable
81
92
  alias_base_name = undecorated_table_name.gsub('.','_')
82
93
 
83
94
  if options.delete(:exclude)
84
- tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ?", t]) }.join(" OR ")
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
+
85
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)})"
86
102
 
87
103
  elsif options.delete(:any)
88
104
  # get tags, drop out if nothing returned (we need at least one)
89
- tags = ActsAsTaggableOn::Tag.named_any(tag_list)
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
+
90
111
  return scoped(:conditions => "1 = 0") unless tags.length > 0
91
112
 
92
113
  # setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123
93
114
  # avoid ambiguous column name
94
115
  taggings_context = context ? "_#{context}" : ''
95
116
 
96
- #TODO: fix alias to be smaller
97
- taggings_alias = "#{alias_base_name}#{taggings_context}_taggings_#{tags.map(&:safe_name).join('_')}_#{rand(1024)}"
117
+ taggings_alias = adjust_taggings_alias(
118
+ "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{sha_prefix(tags.map(&:name).join('_'))}"
119
+ )
98
120
 
99
121
  tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
100
122
  " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
@@ -112,9 +134,8 @@ module ActsAsTaggableOn::Taggable
112
134
  return empty_result unless tags.length == tag_list.length
113
135
 
114
136
  tags.each do |tag|
115
- prefix = "#{tag.safe_name}_#{rand(1024)}"
116
137
 
117
- taggings_alias = "#{alias_base_name}_taggings_#{prefix}"
138
+ taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{sha_prefix(tag.name)}")
118
139
 
119
140
  tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
120
141
  " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
@@ -135,7 +156,7 @@ module ActsAsTaggableOn::Taggable
135
156
  end
136
157
  end
137
158
 
138
- taggings_alias, tags_alias = "#{alias_base_name}_taggings_group", "#{alias_base_name}_tags_group"
159
+ taggings_alias, tags_alias = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group"
139
160
 
140
161
  if options.delete(:match_all)
141
162
  joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
@@ -158,6 +179,13 @@ module ActsAsTaggableOn::Taggable
158
179
  def is_taggable?
159
180
  true
160
181
  end
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
161
189
  end
162
190
 
163
191
  module InstanceMethods
@@ -226,13 +254,19 @@ module ActsAsTaggableOn::Taggable
226
254
  ##
227
255
  # Returns all tags that are not owned of a given context
228
256
  def tags_on(context)
229
- 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
230
262
  end
231
263
 
232
264
  def set_tag_list_on(context, new_list)
233
265
  add_custom_context(context)
234
266
 
235
267
  variable_name = "@#{context.to_s.singularize}_list"
268
+ process_dirty_object(context, new_list) unless custom_contexts.include?(context.to_s)
269
+
236
270
  instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(new_list))
237
271
  end
238
272
 
@@ -240,6 +274,20 @@ module ActsAsTaggableOn::Taggable
240
274
  custom_contexts + self.class.tag_types.map(&:to_s)
241
275
  end
242
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
+
243
291
  def reload(*args)
244
292
  self.class.tag_types.each do |context|
245
293
  instance_variable_set("@#{context.to_s.singularize}_list", nil)
@@ -253,21 +301,38 @@ module ActsAsTaggableOn::Taggable
253
301
  tagging_contexts.each do |context|
254
302
  next unless tag_list_cache_set_on(context)
255
303
 
304
+ # List of currently assigned tag names
256
305
  tag_list = tag_list_cache_on(context).uniq
257
306
 
258
307
  # Find existing tags or create non-existing tags:
259
- 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)
260
309
 
310
+ # Tag objects for currently assigned tags
261
311
  current_tags = tags_on(context)
262
- old_tags = current_tags - tag_list
263
- new_tags = tag_list - current_tags
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
264
327
 
265
328
  # Find taggings to remove:
266
- old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil,
267
- :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
268
333
 
334
+ # Destroy old taggings:
269
335
  if old_taggings.present?
270
- # Destroy old taggings:
271
336
  ActsAsTaggableOn::Tagging.destroy_all "#{ActsAsTaggableOn::Tagging.primary_key}".to_sym => old_taggings.map(&:id)
272
337
  end
273
338
 
@@ -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
@@ -31,12 +31,16 @@ module ActsAsTaggableOn::Taggable
31
31
  module InstanceMethods
32
32
  def owner_tags_on(owner, context)
33
33
  if owner.nil?
34
- base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ?), context.to_s]).all
34
+ scope = base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ?), context.to_s])
35
35
  else
36
- 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]).all
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
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
40
44
  end
41
45
 
42
46
  def cached_owned_tag_list_on(context)
@@ -73,21 +77,38 @@ module ActsAsTaggableOn::Taggable
73
77
  def save_owned_tags
74
78
  tagging_contexts.each do |context|
75
79
  cached_owned_tag_list_on(context).each do |owner, tag_list|
80
+
76
81
  # Find existing tags or create non-existing tags:
77
- 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)
78
83
 
79
- owned_tags = owner_tags_on(owner, context)
80
- old_tags = owned_tags - tag_list
81
- 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
82
101
 
83
102
  # Find all taggings that belong to the taggable (self), are owned by the owner,
84
103
  # have the correct context, and are removed from the list.
85
- old_taggings = ActsAsTaggableOn::Tagging.where(:taggable_id => id, :taggable_type => self.class.base_class.to_s,
86
- :tagger_type => owner.class.to_s, :tagger_id => owner.id,
87
- :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
88
109
 
110
+ # Destroy old taggings:
89
111
  if old_taggings.present?
90
- # Destroy old taggings:
91
112
  ActsAsTaggableOn::Tagging.destroy_all(:id => old_taggings.map(&:id))
92
113
  end
93
114
 
@@ -60,7 +60,7 @@ module ActsAsTaggableOn::Taggable
60
60
 
61
61
  exclude_self = "#{klass.table_name}.#{klass.primary_key} != #{id} AND" if [self.class.base_class, self.class].include? klass
62
62
 
63
- group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(klass) : "#{klass.table_name}.#{klass.primary_key}"
63
+ group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(klass) : "#{klass.table_name}.#{klass.primary_key}"
64
64
 
65
65
  klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}) AS count",
66
66
  :from => "#{klass.table_name}, #{ActsAsTaggableOn::Tag.table_name}, #{ActsAsTaggableOn::Tagging.table_name}",
@@ -12,15 +12,16 @@ 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} ? ESCAPE '!'", escape_like(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} ? ESCAPE '!'", escape_like(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)
@@ -66,15 +67,11 @@ module ActsAsTaggableOn
66
67
  read_attribute(:count).to_i
67
68
  end
68
69
 
69
- def safe_name
70
- name.gsub(/[^a-zA-Z0-9]/, '')
71
- end
72
-
73
70
  class << self
74
71
  private
75
72
  def comparable_name(str)
76
- RUBY_VERSION >= "1.9" ? str.downcase : str.mb_chars.downcase
73
+ str.mb_chars.downcase.to_s
77
74
  end
78
75
  end
79
76
  end
80
- 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