acts-as-taggable-on 2.0.0 → 3.2.2

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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.travis.yml +23 -0
  4. data/Appraisals +20 -0
  5. data/CHANGELOG.md +112 -0
  6. data/CONTRIBUTING.md +44 -0
  7. data/Gemfile +9 -5
  8. data/Guardfile +5 -0
  9. data/{MIT-LICENSE → LICENSE.md} +1 -1
  10. data/README.md +385 -0
  11. data/Rakefile +13 -53
  12. data/UPGRADING.md +13 -0
  13. data/acts-as-taggable-on.gemspec +38 -0
  14. data/{lib/generators/acts_as_taggable_on/migration/templates/active_record/migration.rb → db/migrate/1_acts_as_taggable_on_migration.rb} +5 -3
  15. data/db/migrate/2_add_missing_unique_indices.rb +19 -0
  16. data/db/migrate/3_add_taggings_counter_cache_to_tags.rb +14 -0
  17. data/gemfiles/activerecord_3.2.gemfile +15 -0
  18. data/gemfiles/activerecord_4.0.gemfile +15 -0
  19. data/gemfiles/activerecord_4.1.gemfile +15 -0
  20. data/gemfiles/activerecord_edge.gemfile +16 -0
  21. data/lib/acts-as-taggable-on.rb +65 -22
  22. data/lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb +53 -24
  23. data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +120 -42
  24. data/lib/acts_as_taggable_on/acts_as_taggable_on/compatibility.rb +35 -0
  25. data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +309 -124
  26. data/lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb +36 -0
  27. data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +96 -72
  28. data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +43 -36
  29. data/lib/acts_as_taggable_on/engine.rb +5 -0
  30. data/lib/acts_as_taggable_on/tag.rb +133 -63
  31. data/lib/acts_as_taggable_on/tag_list.rb +162 -81
  32. data/lib/acts_as_taggable_on/taggable.rb +101 -0
  33. data/lib/acts_as_taggable_on/{acts_as_tagger.rb → tagger.rb} +34 -13
  34. data/lib/acts_as_taggable_on/tagging.rb +28 -18
  35. data/lib/acts_as_taggable_on/tags_helper.rb +12 -14
  36. data/lib/acts_as_taggable_on/utils.rb +62 -0
  37. data/lib/acts_as_taggable_on/version.rb +4 -0
  38. data/lib/acts_as_taggable_on.rb +6 -0
  39. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +189 -195
  40. data/spec/acts_as_taggable_on/acts_as_tagger_spec.rb +78 -81
  41. data/spec/acts_as_taggable_on/caching_spec.rb +76 -0
  42. data/spec/acts_as_taggable_on/related_spec.rb +89 -0
  43. data/spec/acts_as_taggable_on/single_table_inheritance_spec.rb +210 -0
  44. data/spec/acts_as_taggable_on/tag_list_spec.rb +149 -61
  45. data/spec/acts_as_taggable_on/tag_spec.rb +245 -65
  46. data/spec/acts_as_taggable_on/taggable_spec.rb +776 -187
  47. data/spec/acts_as_taggable_on/tagger_spec.rb +119 -70
  48. data/spec/acts_as_taggable_on/tagging_spec.rb +17 -21
  49. data/spec/acts_as_taggable_on/tags_helper_spec.rb +38 -22
  50. data/spec/acts_as_taggable_on/utils_spec.rb +15 -0
  51. data/spec/internal/app/models/altered_inheriting_taggable_model.rb +3 -0
  52. data/spec/internal/app/models/cached_model.rb +3 -0
  53. data/spec/internal/app/models/cached_model_with_array.rb +5 -0
  54. data/spec/internal/app/models/company.rb +15 -0
  55. data/spec/internal/app/models/inheriting_taggable_model.rb +2 -0
  56. data/spec/internal/app/models/market.rb +2 -0
  57. data/spec/internal/app/models/models.rb +90 -0
  58. data/spec/internal/app/models/non_standard_id_taggable_model.rb +8 -0
  59. data/spec/internal/app/models/ordered_taggable_model.rb +4 -0
  60. data/spec/internal/app/models/other_cached_model.rb +3 -0
  61. data/spec/internal/app/models/other_taggable_model.rb +4 -0
  62. data/spec/internal/app/models/student.rb +2 -0
  63. data/spec/internal/app/models/taggable_model.rb +13 -0
  64. data/spec/internal/app/models/untaggable_model.rb +3 -0
  65. data/spec/internal/app/models/user.rb +3 -0
  66. data/spec/internal/config/database.yml.sample +19 -0
  67. data/spec/internal/db/schema.rb +97 -0
  68. data/spec/schema.rb +57 -18
  69. data/spec/spec_helper.rb +12 -47
  70. data/spec/support/array.rb +9 -0
  71. data/spec/support/database.rb +42 -0
  72. data/spec/support/database_cleaner.rb +21 -0
  73. metadata +286 -63
  74. data/CHANGELOG +0 -25
  75. data/README.rdoc +0 -221
  76. data/VERSION +0 -1
  77. data/generators/acts_as_taggable_on_migration/acts_as_taggable_on_migration_generator.rb +0 -7
  78. data/generators/acts_as_taggable_on_migration/templates/migration.rb +0 -29
  79. data/lib/acts_as_taggable_on/acts_as_taggable_on.rb +0 -53
  80. data/lib/acts_as_taggable_on/compatibility/Gemfile +0 -6
  81. data/lib/acts_as_taggable_on/compatibility/active_record_backports.rb +0 -17
  82. data/lib/generators/acts_as_taggable_on/migration/migration_generator.rb +0 -31
  83. data/rails/init.rb +0 -1
  84. data/spec/bm.rb +0 -52
  85. data/spec/models.rb +0 -30
  86. /data/{spec/spec.opts → .rspec} +0 -0
@@ -0,0 +1,35 @@
1
+ module ActsAsTaggableOn::Compatibility
2
+ def has_many_with_taggable_compatibility(name, options = {}, &extention)
3
+ if ActsAsTaggableOn::Utils.active_record4?
4
+ scope, opts = build_taggable_scope_and_options(options)
5
+ has_many(name, scope, opts, &extention)
6
+ else
7
+ has_many(name, options, &extention)
8
+ end
9
+ end
10
+
11
+ def build_taggable_scope_and_options(opts)
12
+ scope_opts, opts = parse_taggable_options(opts)
13
+
14
+ unless scope_opts.empty?
15
+ scope = -> {
16
+ scope_opts.inject(self) { |result, hash| result.send(*hash) }
17
+ }
18
+ return [scope, opts]
19
+ end
20
+
21
+ [nil, opts]
22
+ end
23
+
24
+ def parse_taggable_options(opts)
25
+ scope_opts = {}
26
+ [:order, :having, :select, :group, :limit, :offset, :readonly].each do |o|
27
+ scope_opts[o] = opts.delete o if opts[o]
28
+ end
29
+ scope_opts[:where] = opts.delete :conditions if opts[:conditions]
30
+ scope_opts[:joins] = opts.delete :include if opts [:include]
31
+ scope_opts[:distinct] = opts.delete :uniq if opts[:uniq]
32
+
33
+ [scope_opts, opts]
34
+ end
35
+ end
@@ -1,31 +1,43 @@
1
1
  module ActsAsTaggableOn::Taggable
2
2
  module Core
3
3
  def self.included(base)
4
- base.send :include, ActsAsTaggableOn::Taggable::Core::InstanceMethods
5
4
  base.extend ActsAsTaggableOn::Taggable::Core::ClassMethods
6
5
 
7
6
  base.class_eval do
8
7
  attr_writer :custom_contexts
9
8
  after_save :save_tags
10
9
  end
11
-
10
+
12
11
  base.initialize_acts_as_taggable_on_core
13
12
  end
14
-
13
+
15
14
  module ClassMethods
16
15
  def initialize_acts_as_taggable_on_core
16
+ include taggable_mixin
17
17
  tag_types.map(&:to_s).each do |tags_type|
18
- tag_type = tags_type.to_s.singularize
18
+ tag_type = tags_type.to_s.singularize
19
19
  context_taggings = "#{tag_type}_taggings".to_sym
20
- context_tags = tags_type.to_sym
20
+ context_tags = tags_type.to_sym
21
+ taggings_order = (preserve_tag_order? ? "#{ActsAsTaggableOn::Tagging.table_name}.id" : [])
21
22
 
22
23
  class_eval do
23
- has_many context_taggings, :as => :taggable, :dependent => :destroy, :include => :tag, :class_name => "Tagging",
24
- :conditions => ['#{Tagging.table_name}.tagger_id IS NULL AND #{Tagging.table_name}.context = ?', tags_type]
25
- has_many context_tags, :through => context_taggings, :source => :tag
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_with_taggable_compatibility context_taggings, as: :taggable,
27
+ dependent: :destroy,
28
+ class_name: 'ActsAsTaggableOn::Tagging',
29
+ order: taggings_order,
30
+ conditions: ["#{ActsAsTaggableOn::Tagging.table_name}.context = (?)", tags_type],
31
+ include: :tag
32
+
33
+ has_many_with_taggable_compatibility context_tags, through: context_taggings,
34
+ source: :tag,
35
+ class_name: 'ActsAsTaggableOn::Tag',
36
+ order: taggings_order
37
+
26
38
  end
27
39
 
28
- class_eval %(
40
+ taggable_mixin.class_eval <<-RUBY, __FILE__, __LINE__ + 1
29
41
  def #{tag_type}_list
30
42
  tag_list_on('#{tags_type}')
31
43
  end
@@ -37,18 +49,18 @@ module ActsAsTaggableOn::Taggable
37
49
  def all_#{tags_type}_list
38
50
  all_tags_list_on('#{tags_type}')
39
51
  end
40
- )
41
- end
52
+ RUBY
53
+ end
42
54
  end
43
-
44
- def acts_as_taggable_on(*args)
45
- super(*args)
55
+
56
+ def taggable_on(preserve_tag_order, *tag_types)
57
+ super(preserve_tag_order, *tag_types)
46
58
  initialize_acts_as_taggable_on_core
47
59
  end
48
-
60
+
49
61
  # all column names are necessary for PostgreSQL group clause
50
62
  def grouped_column_names_for(object)
51
- object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
63
+ object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(', ')
52
64
  end
53
65
 
54
66
  ##
@@ -58,180 +70,353 @@ module ActsAsTaggableOn::Taggable
58
70
  # @param [Hash] options A hash of options to alter you query:
59
71
  # * <tt>:exclude</tt> - if set to true, return objects that are *NOT* tagged with the specified tags
60
72
  # * <tt>:any</tt> - if set to true, return objects that are tagged with *ANY* of the specified tags
73
+ # * <tt>:order_by_matching_tag_count</tt> - if set to true and used with :any, sort by objects matching the most tags, descending
61
74
  # * <tt>:match_all</tt> - if set to true, return objects that are *ONLY* tagged with the specified tags
75
+ # * <tt>:owned_by</tt> - return objects that are *ONLY* owned by the owner
62
76
  #
63
77
  # Example:
64
78
  # User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
65
79
  # User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
66
80
  # User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
81
+ # User.tagged_with("awesome", "cool", :any => true, :order_by_matching_tag_count => true) # Sort by users who match the most tags, descending
67
82
  # User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
83
+ # User.tagged_with("awesome", "cool", :owned_by => foo ) # Users that are tagged with just awesome and cool by 'foo'
68
84
  def tagged_with(tags, options = {})
69
- tag_list = TagList.from(tags)
85
+ tag_list = ActsAsTaggableOn::TagList.from(tags)
86
+ empty_result = where('1 = 0')
70
87
 
71
- return {} if tag_list.empty?
88
+ return empty_result if tag_list.empty?
72
89
 
73
90
  joins = []
74
91
  conditions = []
92
+ having = []
93
+ select_clause = []
94
+ order_by = []
75
95
 
76
96
  context = options.delete(:on)
97
+ owned_by = options.delete(:owned_by)
98
+ alias_base_name = undecorated_table_name.gsub('.', '_')
99
+ quote = ActsAsTaggableOn::Utils.using_postgresql? ? '"' : ''
77
100
 
78
101
  if options.delete(:exclude)
79
- tags_conditions = tag_list.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
80
- conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND (#{tags_conditions}) WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
102
+ if options.delete(:wild)
103
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{ActsAsTaggableOn::Utils.like_operator} ? ESCAPE '!'", "%#{ActsAsTaggableOn::Utils.escape_like(t)}%"]) }.join(' OR ')
104
+ else
105
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{ActsAsTaggableOn::Utils.like_operator} ?", t]) }.join(' OR ')
106
+ end
107
+
108
+ 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, nil)})"
109
+
110
+ if owned_by
111
+ joins << "JOIN #{ActsAsTaggableOn::Tagging.table_name}" \
112
+ " ON #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" \
113
+ " AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name, nil)}" \
114
+ " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = #{quote_value(owned_by.id, nil)}" \
115
+ " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = #{quote_value(owned_by.class.base_class.to_s, nil)}"
116
+ end
81
117
 
82
118
  elsif options.delete(:any)
83
- tags_conditions = tag_list.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
84
- conditions << "#{table_name}.#{primary_key} IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND (#{tags_conditions}) WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
119
+ # get tags, drop out if nothing returned (we need at least one)
120
+ tags = if options.delete(:wild)
121
+ ActsAsTaggableOn::Tag.named_like_any(tag_list)
122
+ else
123
+ ActsAsTaggableOn::Tag.named_any(tag_list)
124
+ end
85
125
 
86
- else
87
- tags = Tag.named_any(tag_list)
88
- return where("1 = 0") unless tags.length == tag_list.length
126
+ return empty_result unless tags.length > 0
89
127
 
90
- tags.each do |tag|
91
- safe_tag = tag.name.gsub(/[^a-zA-Z0-9]/, '')
92
- prefix = "#{safe_tag}_#{rand(1024)}"
128
+ # setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123
129
+ # avoid ambiguous column name
130
+ taggings_context = context ? "_#{context}" : ''
131
+
132
+ taggings_alias = adjust_taggings_alias(
133
+ "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tags.map(&:name).join('_'))}"
134
+ )
135
+
136
+ tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" \
137
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" \
138
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name, nil)}"
139
+ tagging_join << ' AND ' + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
140
+
141
+ # don't need to sanitize sql, map all ids and join with OR logic
142
+ conditions << tags.map { |t| "#{taggings_alias}.tag_id = #{quote_value(t.id, nil)}" }.join(' OR ')
143
+ select_clause = " #{table_name}.*" unless context and tag_types.one?
144
+
145
+ if owned_by
146
+ tagging_join << ' AND ' +
147
+ sanitize_sql([
148
+ "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
149
+ owned_by.id,
150
+ owned_by.class.base_class.to_s
151
+ ])
152
+ end
153
+
154
+ joins << tagging_join
155
+ group = "#{table_name}.#{primary_key}"
156
+ else
157
+ tags = ActsAsTaggableOn::Tag.named_any(tag_list)
93
158
 
94
- taggings_alias = "#{table_name}_taggings_#{prefix}"
159
+ return empty_result unless tags.length == tag_list.length
95
160
 
96
- tagging_join = "JOIN #{Tagging.table_name} #{taggings_alias}" +
97
- " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
98
- " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
99
- " AND #{taggings_alias}.tag_id = #{tag.id}"
100
- tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
161
+ tags.each do |tag|
162
+ taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tag.name)}")
163
+ tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" \
164
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
165
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name, nil)}" +
166
+ " AND #{taggings_alias}.tag_id = #{quote_value(tag.id, nil)}"
167
+
168
+ tagging_join << ' AND ' + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
169
+
170
+ if owned_by
171
+ tagging_join << ' AND ' +
172
+ sanitize_sql([
173
+ "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
174
+ owned_by.id,
175
+ owned_by.class.base_class.to_s
176
+ ])
177
+ end
101
178
 
102
179
  joins << tagging_join
103
180
  end
104
181
  end
105
182
 
106
- taggings_alias, tags_alias = "#{table_name}_taggings_group", "#{table_name}_tags_group"
183
+ group ||= [] # Rails interprets this as a no-op in the group() call below
184
+ if options.delete(:order_by_matching_tag_count)
185
+ select_clause = "#{table_name}.*, COUNT(#{taggings_alias}.tag_id) AS #{taggings_alias}_count"
186
+ group_columns = ActsAsTaggableOn::Utils.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
187
+ group = group_columns
188
+ order_by << "#{taggings_alias}_count DESC"
189
+
190
+ elsif options.delete(:match_all)
191
+ taggings_alias, _ = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group"
192
+ joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" \
193
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" \
194
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name, nil)}"
107
195
 
108
- if options.delete(:match_all)
109
- joins << "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias}" +
110
- " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
111
- " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
196
+ joins << ' AND ' + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
112
197
 
113
- group = "#{grouped_column_names_for(self)} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
198
+ group_columns = ActsAsTaggableOn::Utils.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
199
+ group = group_columns
200
+ having = "COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
114
201
  end
115
202
 
203
+ order_by << options[:order] if options[:order].present?
116
204
 
117
- scoped(:joins => joins.join(" "),
118
- :group => group,
119
- :conditions => conditions.join(" AND "),
120
- :readonly => false)
205
+ select(select_clause)
206
+ .joins(joins.join(' '))
207
+ .where(conditions.join(' AND '))
208
+ .group(group)
209
+ .having(having)
210
+ .order(order_by.join(', '))
211
+ .readonly(false)
121
212
  end
122
213
 
123
214
  def is_taggable?
124
215
  true
125
216
  end
126
- end
127
-
128
- module InstanceMethods
129
- # all column names are necessary for PostgreSQL group clause
130
- def grouped_column_names_for(object)
131
- self.class.grouped_column_names_for(object)
132
- end
133
217
 
134
- def custom_contexts
135
- @custom_contexts ||= []
218
+ def adjust_taggings_alias(taggings_alias)
219
+ if taggings_alias.size > 75
220
+ taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias)
221
+ end
222
+ taggings_alias
136
223
  end
137
224
 
138
- def is_taggable?
139
- self.class.is_taggable?
225
+ def taggable_mixin
226
+ @taggable_mixin ||= Module.new
140
227
  end
228
+ end
141
229
 
142
- def add_custom_context(value)
143
- custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s)
144
- end
230
+ # all column names are necessary for PostgreSQL group clause
231
+ def grouped_column_names_for(object)
232
+ self.class.grouped_column_names_for(object)
233
+ end
145
234
 
146
- def cached_tag_list_on(context)
147
- self["cached_#{context.to_s.singularize}_list"]
148
- end
235
+ def custom_contexts
236
+ @custom_contexts ||= []
237
+ end
149
238
 
150
- def tag_list_cache_set_on(context)
151
- variable_name = "@#{context.to_s.singularize}_list"
152
- !instance_variable_get(variable_name).nil?
153
- end
239
+ def is_taggable?
240
+ self.class.is_taggable?
241
+ end
154
242
 
155
- def tag_list_cache_on(context)
156
- variable_name = "@#{context.to_s.singularize}_list"
157
- instance_variable_get(variable_name) || instance_variable_set(variable_name, TagList.new(tags_on(context).map(&:name)))
158
- end
243
+ def add_custom_context(value)
244
+ custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s)
245
+ end
159
246
 
160
- def tag_list_on(context)
161
- add_custom_context(context)
162
- tag_list_cache_on(context)
163
- end
247
+ def cached_tag_list_on(context)
248
+ self["cached_#{context.to_s.singularize}_list"]
249
+ end
164
250
 
165
- def all_tags_list_on(context)
166
- variable_name = "@all_#{context.to_s.singularize}_list"
167
- return instance_variable_get(variable_name) if instance_variable_get(variable_name)
251
+ def tag_list_cache_set_on(context)
252
+ variable_name = "@#{context.to_s.singularize}_list"
253
+ instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
254
+ end
168
255
 
169
- instance_variable_set(variable_name, TagList.new(all_tags_on(context).map(&:name)).freeze)
256
+ def tag_list_cache_on(context)
257
+ variable_name = "@#{context.to_s.singularize}_list"
258
+ if instance_variable_get(variable_name)
259
+ instance_variable_get(variable_name)
260
+ elsif cached_tag_list_on(context) && self.class.caching_tag_list_on?(context)
261
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(cached_tag_list_on(context)))
262
+ else
263
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(tags_on(context).map(&:name)))
170
264
  end
265
+ end
171
266
 
172
- ##
173
- # Returns all tags of a given context
174
- def all_tags_on(context)
175
- opts = ["#{Tagging.table_name}.context = ?", context.to_s]
176
- base_tags.where(opts).order("#{Tagging.table_name}.created_at").group("#{Tagging.table_name}.tag_id").all
177
- end
267
+ def tag_list_on(context)
268
+ add_custom_context(context)
269
+ tag_list_cache_on(context)
270
+ end
178
271
 
179
- ##
180
- # Returns all tags that are not owned of a given context
181
- def tags_on(context)
182
- base_tags.where(["#{Tagging.table_name}.context = ? AND #{Tagging.table_name}.tagger_id IS NULL", context.to_s]).all
183
- end
272
+ def all_tags_list_on(context)
273
+ variable_name = "@all_#{context.to_s.singularize}_list"
274
+ return instance_variable_get(variable_name) if instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
184
275
 
185
- def set_tag_list_on(context, new_list)
186
- add_custom_context(context)
276
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(all_tags_on(context).map(&:name)).freeze)
277
+ end
187
278
 
188
- variable_name = "@#{context.to_s.singularize}_list"
189
- instance_variable_set(variable_name, TagList.from(new_list))
190
- end
279
+ ##
280
+ # Returns all tags of a given context
281
+ def all_tags_on(context)
282
+ tagging_table_name = ActsAsTaggableOn::Tagging.table_name
283
+
284
+ opts = ["#{tagging_table_name}.context = ?", context.to_s]
285
+ scope = base_tags.where(opts)
191
286
 
192
- def tagging_contexts
193
- custom_contexts + self.class.tag_types.map(&:to_s)
287
+ if ActsAsTaggableOn::Utils.using_postgresql?
288
+ group_columns = grouped_column_names_for(ActsAsTaggableOn::Tag)
289
+ scope.order("max(#{tagging_table_name}.created_at)").group(group_columns)
290
+ else
291
+ scope.group("#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}")
292
+ end.to_a
293
+ end
294
+
295
+ ##
296
+ # Returns all tags that are not owned of a given context
297
+ def tags_on(context)
298
+ scope = base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s])
299
+ # when preserving tag order, return tags in created order
300
+ # if we added the order to the association this would always apply
301
+ scope = scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") if self.class.preserve_tag_order?
302
+ scope
303
+ end
304
+
305
+ def set_tag_list_on(context, new_list)
306
+ add_custom_context(context)
307
+
308
+ variable_name = "@#{context.to_s.singularize}_list"
309
+ process_dirty_object(context, new_list) unless custom_contexts.include?(context.to_s)
310
+
311
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(new_list))
312
+ end
313
+
314
+ def tagging_contexts
315
+ custom_contexts + self.class.tag_types.map(&:to_s)
316
+ end
317
+
318
+ def process_dirty_object(context, new_list)
319
+ value = new_list.is_a?(Array) ? ActsAsTaggableOn::TagList.new(new_list) : new_list
320
+ attrib = "#{context.to_s.singularize}_list"
321
+
322
+ if changed_attributes.include?(attrib)
323
+ # The attribute already has an unsaved change.
324
+ old = changed_attributes[attrib]
325
+ changed_attributes.delete(attrib) if old.to_s == value.to_s
326
+ else
327
+ old = tag_list_on(context).to_s
328
+ changed_attributes[attrib] = old if old.to_s != value.to_s
194
329
  end
330
+ end
195
331
 
196
- def reload
197
- self.class.tag_types.each do |context|
198
- instance_variable_set("@#{context.to_s.singularize}_list", nil)
199
- instance_variable_set("@all_#{context.to_s.singularize}_list", nil)
200
- end
201
-
202
- super
332
+ def reload(*args)
333
+ self.class.tag_types.each do |context|
334
+ instance_variable_set("@#{context.to_s.singularize}_list", nil)
335
+ instance_variable_set("@all_#{context.to_s.singularize}_list", nil)
203
336
  end
204
337
 
205
- def save_tags
206
- tagging_contexts.each do |context|
207
- next unless tag_list_cache_set_on(context)
338
+ super(*args)
339
+ end
208
340
 
209
- tag_list = tag_list_cache_on(context).uniq
341
+ ##
342
+ # Find existing tags or create non-existing tags
343
+ def load_tags(tag_list)
344
+ ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list)
345
+ end
210
346
 
211
- # Find existing tags or create non-existing tags:
212
- tag_list = Tag.find_or_create_all_with_like_by_name(tag_list)
347
+ def save_tags
348
+ tagging_contexts.each do |context|
349
+ next unless tag_list_cache_set_on(context)
350
+ # List of currently assigned tag names
351
+ tag_list = tag_list_cache_on(context).uniq
213
352
 
214
- current_tags = tags_on(context)
215
- old_tags = current_tags - tag_list
216
- new_tags = tag_list - current_tags
217
-
218
- # Find taggings to remove:
219
- old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil,
220
- :context => context.to_s, :tag_id => old_tags).all
353
+ # Find existing tags or create non-existing tags:
354
+ tags = find_or_create_tags_from_list_with_context(tag_list, context)
221
355
 
222
- if old_taggings.present?
223
- # Destroy old taggings:
224
- Tagging.destroy_all :id => old_taggings.map(&:id)
225
- end
356
+ # Tag objects for currently assigned tags
357
+ current_tags = tags_on(context)
358
+
359
+ # Tag maintenance based on whether preserving the created order of tags
360
+ if self.class.preserve_tag_order?
361
+ old_tags, new_tags = current_tags - tags, tags - current_tags
362
+
363
+ shared_tags = current_tags & tags
364
+
365
+ if shared_tags.any? && tags[0...shared_tags.size] != shared_tags
366
+ index = shared_tags.each_with_index { |_, i| break i unless shared_tags[i] == tags[i] }
226
367
 
227
- # Create new taggings:
228
- new_tags.each do |tag|
229
- taggings.create!(:tag_id => tag.id, :context => context.to_s, :taggable => self)
368
+ # Update arrays of tag objects
369
+ old_tags |= current_tags[index...current_tags.size]
370
+ new_tags |= current_tags[index...current_tags.size] & shared_tags
371
+
372
+ # Order the array of tag objects to match the tag list
373
+ new_tags = tags.map do |t|
374
+ new_tags.find { |n| n.name.downcase == t.name.downcase }
375
+ end.compact
230
376
  end
377
+ else
378
+ # Delete discarded tags and create new tags
379
+ old_tags = current_tags - tags
380
+ new_tags = tags - current_tags
231
381
  end
232
382
 
233
- true
383
+ # Destroy old taggings:
384
+ if old_tags.present?
385
+ ActsAsTaggableOn::Tagging.destroy_all(tagger_type: nil, tagger_id: nil, context: context.to_s, tag_id: old_tags)
386
+ end
387
+
388
+ # Create new taggings:
389
+ new_tags.each do |tag|
390
+ taggings.create!(tag_id: tag.id, context: context.to_s, taggable: self)
391
+ end
234
392
  end
393
+
394
+ true
395
+ end
396
+
397
+ private
398
+
399
+ ##
400
+ # Override this hook if you wish to subclass {ActsAsTaggableOn::Tag} --
401
+ # context is provided so that you may conditionally use a Tag subclass
402
+ # only for some contexts.
403
+ #
404
+ # @example Custom Tag class for one context
405
+ # class Company < ActiveRecord::Base
406
+ # acts_as_taggable_on :markets, :locations
407
+ #
408
+ # def find_or_create_tags_from_list_with_context(tag_list, context)
409
+ # if context.to_sym == :markets
410
+ # MarketTag.find_or_create_all_with_like_by_name(tag_list)
411
+ # else
412
+ # super
413
+ # end
414
+ # end
415
+ #
416
+ # @param [Array<String>] tag_list Tags to find or create
417
+ # @param [Symbol] context The tag context for the tag_list
418
+ def find_or_create_tags_from_list_with_context(tag_list, _context)
419
+ load_tags(tag_list)
235
420
  end
236
421
  end
237
- end
422
+ end
@@ -0,0 +1,36 @@
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
+
14
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
15
+ def #{tag_type}_list_changed?
16
+ changed_attributes.include?("#{tag_type}_list")
17
+ end
18
+
19
+ def #{tag_type}_list_was
20
+ changed_attributes.include?("#{tag_type}_list") ? changed_attributes["#{tag_type}_list"] : __send__("#{tag_type}_list")
21
+ end
22
+
23
+ def #{tag_type}_list_change
24
+ [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list")
25
+ end
26
+
27
+ def #{tag_type}_list_changes
28
+ [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list")
29
+ end
30
+ RUBY
31
+
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end