acts-as-taggable-on 2.0.0.pre5 → 3.5.0

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