acts_as_taggable 1.0.4 → 2.0.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 (5) hide show
  1. data/CHANGELOG +10 -0
  2. data/README +89 -70
  3. data/lib/taggable.rb +610 -467
  4. data/test/acts_as_taggable_test.rb +412 -384
  5. metadata +26 -39
data/lib/taggable.rb CHANGED
@@ -1,467 +1,610 @@
1
- require 'active_support'
2
- require 'active_record'
3
-
4
- module ActiveRecord
5
- module Acts #:nodoc:
6
- module Taggable #:nodoc:
7
-
8
- def self.append_features(base)
9
- super
10
- base.extend(ClassMethods)
11
- end
12
-
13
- def self.split_tag_names(tags, separator)
14
- tag_names = []
15
- if tags.is_a?(Array)
16
- tag_names << tags
17
- elsif tags.is_a?(String)
18
- tag_names << (separator.is_a?(Proc) ? separator.call(tags) : tags.split(separator))
19
- end
20
- tag_names = tag_names.flatten.map { |name| name.strip }.uniq.compact #straight 'em up
21
- end
22
-
23
- # This mixin provides an easy way for addind tagging capabilities (also
24
- # known as folksnomy) to your active record objects. It allows you to add
25
- # tags to your objects as well as search for tagged objects.
26
- #
27
- # It assumes you are using a fully-normalized tagging database schema. For
28
- # that, you need a table (by default, named +tags+) to hold all tags in your
29
- # application and this table must have a primary key (normally a +id+ int
30
- # autonumber column) and a +name+ varchar column. You must also define a model class
31
- # related to this table (by default, named +Tag+).
32
- #
33
- # All tag names will be stored in this tags table. Taggable objects should reside
34
- # in their own tables, like any other object. Tagging objects is perfomed by
35
- # the +acts_as_taggable+ mixin using a +has_and_belong_to_many+ relationship that is
36
- # automatically created on the taggable class, and as so, a join table must exist
37
- # between the tags table and the taggable object table.
38
- #
39
- # The name of the join table, by default, always follow the form
40
- # '[tags_table_name]_[taggable_object_table_name]' even if the taggable object
41
- # table name precedes the tags table name alphabetically (for example, tags_photos).
42
- # This is different from the regular +has_and_belongs_to_many+ convention and
43
- # allows all your join tables to share a common prefix (which is the tags table name).
44
- #
45
- # The join table must be composed of the foreign keys from the tags table and the
46
- # taggable object table, so for instance, if we have a tags table named +tags+ (related
47
- # to a +Tag+ model) and a taggable +photos+ table (related to a +Photo+ model),
48
- # there should be a join table +tags_photos+ with int FK columns +photo_id+ and +tag_id+.
49
- # If you don�t use a explicit full model related to the join table (thru the
50
- # +:join_class_name+ option), you must not add a primary key to the join table.
51
- #
52
- # The +acts_as_taggable+ adds the instance methods +tag+, +tag_names+,
53
- # +tag_names= +, +tag_names<< +, +tagged_with? + for adding tags to the object
54
- # and also the class method +find_tagged_with+ method for search tagged objects.
55
- #
56
- # Examples:
57
- #
58
- # class Photo < ActiveRecord::Base
59
- # # this creates a 'tags' collection, thru a has_and_belongs_to_many
60
- # # relationship that utilizes the join table 'tags_photos'.
61
- # acts_as_taggable
62
- # end
63
- #
64
- # photo = Photo.new
65
- #
66
- # # splits and adds to the tags collection
67
- # photo.tag "wine beer alcohol"
68
- #
69
- # # don't need to split since it's an array, but replaces the tags collection
70
- # # trailing and leading spaces are properly removed
71
- # photo.tag [ 'wine ', ' vodka'], :clear => true
72
- #
73
- # photo.tag_names # => [ 'wine', 'vodka' ]
74
- #
75
- # # appends new tags with a different separator
76
- # # the 'wine' tag won�t be duplicated
77
- # photo.tag_names << 'wine, beer, alcohol', :separator => ','
78
- #
79
- # # The difference between +tag_names+ and +tags+ is that +tag_names+
80
- # # holds an array of String objects, mapped from +tags+, while +tags+
81
- # # holds the actual +has_and_belongs_to_many+ collection, and so, is
82
- # # composed of +Tag+ objects.
83
- # photo.tag_names.size # => 4
84
- # photo.tags.size # => 4
85
- #
86
- # # Find photos with 'wine' OR 'whisky'
87
- # Photo.find_tagged_with :any => [ 'wine', 'whisky' ]
88
- #
89
- # # Finds photos with 'wine' AND 'whisky' using a different separator.
90
- # # This is also known as tag combos.
91
- # Photo.find_tagged_with(:all => 'wine+whisky', :separator => '+'
92
- #
93
- # # Gets the top 10 tags for all photos
94
- # Photo.tags_count :limit => 10 # => { 'beer' => 68, 'wine' => 37, 'vodka' => '22', ... }
95
- #
96
- # # Gets the tags count that are greater than 30
97
- # Photo.tags_count :count => '> 30' # => { 'beer' => 68, 'wine' => 37 }
98
- #
99
- # You can also use full join models if you want to take advantage of
100
- # ActiveRecord�s callbacks, timestamping, inheritance and other features
101
- # on the join records as well. For that, you use the +:join_class_name+ option.
102
- # In this case, the join table must have a primary key.
103
- #
104
- # class Person
105
- # # This defines a class +TagPerson+ automagically.
106
- # acts_as_taggable :join_class_name => 'TagPerson'
107
- # end
108
- #
109
- # # We can open the +TagPerson+ class and add features to it.
110
- # class TagPerson
111
- # acts_as_list :scope => :person
112
- # belongs_to :created_by, :class_name => 'User', :foreign_key => 'created_by_id'
113
- # before_save :do_some_validation
114
- # after_save :do_some_stats
115
- # end
116
- #
117
- # # We can do some interesting things with it now
118
- # person = Person.new
119
- # person.tag "wine beer alcohol", :attributes => { :created_by_id => 1 }
120
- # Person.find_tagged_with(:any => 'wine', :condition => "tags_people.created_by_id = 1 AND tags_people.position = 1")
121
- module ClassMethods
122
-
123
- # This method defines a +has_and_belongs_to_many+ relationship between
124
- # the target class and the tag model class. It also adds several instance methods
125
- # for tagging objects of the target class, as well as a class method for searching
126
- # objects that contains specific tags.
127
- #
128
- # The options are:
129
- #
130
- # The +:collection+ parameter receives a symbol defining
131
- # the name of the tag collection method and it defaults to +:tags+.
132
- #
133
- # The +:tag_class_name+ parameter receives the tag model class name and
134
- # it defaults to +'Tag'+.
135
- #
136
- # THe +:join_class_name+ parameter receives the model class name that joins
137
- # the tag model and the taggable model. This automagically defines the join model
138
- # class that can be opened and extended.
139
- #
140
- # The remaining options are passed on to the +has_and_belongs_to_many+ declaration.
141
- # The +:join_table+ parameter is defined by default using the form
142
- # of '[tags_table_name]_[target_class_table_name]', example: +tags_photos+,
143
- # which differs from the standard +has_and_belongs_to_many+ behavior.
144
- def acts_as_taggable(options = {})
145
-
146
- options = { :collection => :tags, :tag_class_name => 'Tag' }.merge(options)
147
- collection_name = options[:collection]
148
- tag_model = options[:tag_class_name].constantize
149
-
150
- default_join_table = "#{tag_model.table_name}_#{self.table_name}"
151
- options[:join_table] ||= default_join_table
152
- options[:foreign_key] ||= self.name.to_s.foreign_key
153
- options[:association_foreign_key] ||= tag_model.to_s.foreign_key
154
-
155
- # not using a simple has_and_belongs_to_many but a full model
156
- # for joining the tags table and the taggable object table
157
- if join_class_name = options[:join_class_name]
158
- Object.class_eval "class #{join_class_name} < ActiveRecord::Base; set_table_name '#{options[:join_table]}' end" unless Object.const_defined?(join_class_name)
159
-
160
- join_model = join_class_name.constantize
161
- tagged = self
162
- join_model.class_eval do
163
- belongs_to :tag, :class_name => tag_model.to_s
164
- belongs_to :tagged, :class_name => tagged.name.to_s
165
-
166
- define_method(:name) { self['name'] ||= tag.name }
167
- end
168
-
169
-
170
- options[:class_name] ||= join_model.to_s
171
- tag_pk, tag_fk = tag_model.primary_key, options[:association_foreign_key]
172
- t, jt = tag_model.table_name, join_model.table_name
173
- options[:finder_sql] ||= "SELECT #{jt}.*, #{t}.name AS name FROM #{jt}, #{t} WHERE #{jt}.#{tag_fk} = #{t}.#{tag_pk} AND #{jt}.#{options[:foreign_key]} = \#{quoted_id}"
174
- else
175
- join_model = nil
176
- end
177
-
178
- # set some class-wide attributes needed in class and instance methods
179
- write_inheritable_attribute(:tag_foreign_key, options[:association_foreign_key])
180
- write_inheritable_attribute(:taggable_foreign_key, options[:foreign_key])
181
- write_inheritable_attribute(:tag_collection_name, collection_name)
182
- write_inheritable_attribute(:tag_model, tag_model)
183
- write_inheritable_attribute(:tags_join_model, join_model)
184
- write_inheritable_attribute(:tags_join_table, options[:join_table])
185
- write_inheritable_attribute(:tag_options, options)
186
-
187
- [ :collection, :tag_class_name, :join_class_name ].each { |key| options.delete(key) } # remove these, we don't need it anymore
188
- [ :join_table, :association_foreign_key ].each { |key| options.delete(key) } if join_model # don�t need this for has_many
189
-
190
- # now, finally add the proper relationships
191
- class_eval do
192
- include ActiveRecord::Acts::Taggable::InstanceMethods
193
- extend ActiveRecord::Acts::Taggable::SingletonMethods
194
-
195
- class_inheritable_reader :tag_collection_name, :tag_model, :tags_join_model,
196
- :tags_options, :tags_join_table,
197
- :tag_foreign_key, :taggable_foreign_key
198
- if join_model
199
- has_many collection_name, options
200
- else
201
- has_and_belongs_to_many collection_name, options
202
- end
203
- end
204
-
205
- end
206
- end
207
-
208
- module SingletonMethods
209
- # This method searches for objects of the taggable class and subclasses that
210
- # contains specific tags associated to them. The tags to be searched for can
211
- # be passed to the +:any+ or +:all+ options, either as a String or an Array.
212
- #
213
- # The options are:
214
- #
215
- # +:any+: searches objects that are related to ANY of the given tags
216
- #
217
- # +:all+: searcher objects that are related to ALL of the the given tags
218
- #
219
- # +:separator+: a string, regex or Proc object that will be used to split the
220
- # tags string passed to +:any+ or +:all+ using a regular +String#split+ method.
221
- # If a Proc is passed, the proc should split the string in any way it wants
222
- # and return an array of strings.
223
- #
224
- # +:conditions+: any additional conditions that should be appended to the
225
- # WHERE clause of the finder SQL. Just like regular +ActiveRecord::Base#find+ methods.
226
- #
227
- # +:order+: the same as used in regular +ActiveRecord::Base#find+ methods.
228
- #
229
- # +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
230
- def find_tagged_with(options = {})
231
- options = { :separator => ' ' }.merge(options)
232
-
233
- tag_names = ActiveRecord::Acts::Taggable.split_tag_names(options[:any] || options[:all], options[:separator])
234
- raise "No tags were passed to :any or :all options" if tag_names.empty?
235
-
236
- o, o_pk, o_fk, t, t_pk, t_fk, jt = set_locals_for_sql
237
- sql = "SELECT #{o}.* FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
238
- AND (#{t}.name = '#{tag_names.join("' OR #{t}.name='")}')
239
- AND #{o}.#{o_pk} = #{jt}.#{o_fk}"
240
- sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
241
- sql << " GROUP BY #{o}.#{o_pk}"
242
- sql << " HAVING COUNT(#{o}.#{o_pk}) = #{tag_names.length}" if options[:all]
243
- sql << " ORDER BY #{options[:order]} " if options[:order]
244
- add_limit!(sql, options)
245
-
246
- find_by_sql(sql)
247
- end
248
-
249
- # This method counts the number of times the tags have been applied to your objects
250
- # and, by default, returns a hash in the form of { 'tag_name' => count, ... }
251
- #
252
- # The options are:
253
- #
254
- # +:raw+: If you just want to get the raw output of the SQL statement (Array of Hashes), instead of the regular tags count Hash, set this to +true+.
255
- #
256
- # +:conditions+: any additional conditions that should be appended to the
257
- # WHERE clause of the SQL. Just like in regular +ActiveRecord::Base#find+ methods.
258
- #
259
- # +:order+: The same as used in +ActiveRecord::Base#find+ methods. By default, this is 'count DESC'.
260
- #
261
- # +:count+: Adds a HAVING clause to the SQL statement, where you can set conditions for the 'count' column. For example: '> 50'
262
- #
263
- # +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
264
- def tags_count(options = {})
265
- options = {:order => 'count DESC'}.merge(options)
266
-
267
- o, o_pk, o_fk, t, t_pk, t_fk, jt = set_locals_for_sql
268
- sql = "SELECT #{t}.#{t_pk} AS id, #{t}.name AS name, COUNT(*) AS count FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
269
- AND #{jt}.#{o_fk} = #{o}.#{o_pk}"
270
- sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
271
- sql << " GROUP BY #{t}.name"
272
- sql << " HAVING count #{options[:count]} " if options[:count]
273
- sql << " ORDER BY #{options[:order]} " if options[:order]
274
- add_limit!(sql, options)
275
- result = connection.select_all(sql)
276
- count = result.inject({}) { |hsh, row| hsh[row['name']] = row['count'].to_i; hsh } unless options[:raw]
277
-
278
- count || result
279
- end
280
-
281
- # Alias for +tags_count+
282
- alias_method :tag_count, :tags_count
283
-
284
- # Finds other records that share the most tags with the record passed
285
- # as the +related+ parameter. Useful for constructing 'Related' or
286
- # 'See Also' boxes and lists.
287
- #
288
- # The options are:
289
- #
290
- # +:limit+: defaults to 5, which means the method will return the top 5 records
291
- # that share the greatest number of tags with the passed one.
292
- def find_related_tagged(related, options = {})
293
- related_id = related.is_a?(self) ? related.id : related
294
- options = { :limit => 5 }.merge(options)
295
-
296
- o, o_pk, o_fk, t, t_pk, t_fk, jt = set_locals_for_sql
297
- sql = "SELECT o.*, COUNT(jt2.#{o_fk}) AS count FROM #{o} o, #{jt} jt, #{t} t, #{jt} jt2
298
- WHERE jt.#{o_fk}=#{related_id} AND t.#{t_pk} = jt.#{t_fk}
299
- AND jt2.#{o_fk} != jt.#{o_fk}
300
- AND jt2.#{t_fk}=jt.#{t_fk} AND o.#{o_pk} = jt2.#{o_fk}
301
- GROUP BY jt2.#{o_fk} ORDER BY count DESC"
302
- add_limit!(sql, options)
303
-
304
- find_by_sql(sql)
305
- end
306
-
307
- # Finds other tags that are related to the tags passed thru the +tags+
308
- # parameter, by finding common records that share similar sets of tags.
309
- # Useful for constructing 'Related tags' lists.
310
- #
311
- # The options are:
312
- #
313
- # +:separator+ => defines the separator (String or Regex) used to split
314
- # the tags parameter and defaults to ' ' (space and line breaks).
315
- #
316
- # +:raw+: If you just want to get the raw output of the SQL statement (Array of Hashes), instead of the regular tags count Hash, set this to +true+.
317
- #
318
- # +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
319
- def find_related_tags(tags, options = {})
320
- tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator])
321
- o, o_pk, o_fk, t, t_pk, t_fk, jt = set_locals_for_sql
322
-
323
- sql = "SELECT jt.#{o_fk} AS o_id FROM #{jt} jt, #{t} t
324
- WHERE jt.#{t_fk} = t.#{t_pk}
325
- AND (t.name IN ('#{tag_names.uniq.join("', '")}'))
326
- GROUP BY jt.#{o_fk}
327
- HAVING COUNT(jt.#{o_fk})=#{tag_names.length}"
328
-
329
- o_ids = connection.select_all(sql).map { |row| row['o_id'] }
330
- return options[:raw] ? [] : {} if o_ids.length < 1
331
-
332
- sql = "SELECT t.#{t_pk} AS id, t.name AS name, COUNT(jt.#{o_fk}) AS count FROM #{jt} jt, #{t} t
333
- WHERE jt.#{o_fk} IN (#{o_ids.join(",")})
334
- AND t.#{t_pk} = jt.#{t_fk}
335
- GROUP BY jt.#{t_fk}
336
- ORDER BY count DESC"
337
- add_limit!(sql, options)
338
-
339
- result = connection.select_all(sql).delete_if { |row| tag_names.include?(row['name']) }
340
- count = result.inject({}) { |hsh, row| hsh[row['name']] = row['count'].to_i; hsh } unless options[:raw]
341
-
342
- count || result
343
- end
344
-
345
- private
346
- def set_locals_for_sql
347
- [ table_name, primary_key, taggable_foreign_key,
348
- tag_model.table_name, tag_model.primary_key, tag_foreign_key,
349
- tags_join_model ? tags_join_model.table_name : tags_join_table ]
350
- end
351
-
352
- end
353
-
354
- module InstanceMethods
355
-
356
- # This method applies tags to the target object, by parsing the tags parameter
357
- # into Tag object instances and adding them to the tag collection of the object.
358
- # If the tag name already exists in the tags table, it just adds a relationship
359
- # to the existing tag record. If it doesn't exist, it then creates a new
360
- # Tag record for it.
361
- #
362
- # The +tags+ parameter can be a +String+, +Array+ or a +Proc+ object.
363
- # If it's a +String+, it's splitted using the +:separator+ specified in
364
- # the +options+ hash. If it's an +Array+ it is flattened and compacted.
365
- # Duplicate entries will be removed as well. Tag names are also stripped
366
- # of trailing and leading whitespaces. If a Proc is passed,
367
- # the proc should split the string in any way it wants and return an array of strings.
368
- #
369
- # The +options+ hash has the following parameters:
370
- #
371
- # +:separator+ => defines the separator (String or Regex) used to split
372
- # the tags parameter and defaults to ' ' (space and line breaks).
373
- #
374
- # +:clear+ => defines whether the existing tag collection will be cleared before
375
- # applying the new +tags+ passed. Defaults to +false+.
376
- def tag(tags, options = {})
377
-
378
- options = { :separator => ' ', :clear => false }.merge(options)
379
- attributes = options[:attributes] || {}
380
-
381
- # parse the tags parameter
382
- tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator])
383
-
384
- # clear the collection if appropriate
385
- tag_collection.clear if options[:clear]
386
-
387
- # append the tag names to the collection
388
- tag_names.each do |name|
389
- # ensure that tag names don't get duplicated
390
- tag_record = tag_model.find_by_name(name) || tag_model.new(:name => name)
391
- if tags_join_model
392
- tag_join_record = tags_join_model.new(attributes)
393
- tag_join_record.tag = tag_record
394
- tag_join_record.tagged = self
395
- tag_collection << tag_join_record unless tagged_with?(name)
396
- else
397
- tag_collection.push_with_attributes(tag_record, attributes) unless tagged_with?(name)
398
- end
399
- end
400
-
401
- end
402
-
403
- # Clears the current tags collection and sets the tag names for this object.
404
- # Equivalent of calling #tag(..., :clear => true)
405
- #
406
- # Another way of appending tags to a existing tags collection is by using
407
- # the +<<+ or +concat+ method on +tag_names+, which is equivalent of calling
408
- # #tag(..., :clear => false).
409
- def tag_names=(tags, options = {})
410
- tag(tags, options.merge(:clear => true))
411
- end
412
-
413
- # Returns an array of strings containing the tags applied to this object.
414
- # If +reload+ is +true+, the tags collection is reloaded.
415
- def tag_names(reload = false)
416
- ary = tag_collection(reload).map { |tag| tag.name }
417
- ary.extend(TagNamesMixin)
418
- ary.set_tag_container(self)
419
- ary
420
- end
421
-
422
- # Checks to see if this object has been tagged with +tag_name+.
423
- # If +reload+ is true, reloads the tag collection before doing the check.
424
- def tagged_with?(tag_name, reload = false)
425
- tag_names(reload).include?(tag_name)
426
- end
427
-
428
- # Calls +find_related_tagged+ passing +self+ as the +related+ parameter.
429
- def tagged_related(options = {})
430
- self.class.find_related_tagged(self.id, options)
431
- end
432
-
433
- private
434
- def tag_model
435
- self.class.tag_model
436
- end
437
-
438
- def tag_collection(reload = false)
439
- send(self.class.tag_collection_name, reload)
440
- end
441
-
442
- def tags_join_model
443
- self.class.tags_join_model
444
- end
445
-
446
- end
447
-
448
- module TagNamesMixin #:nodoc:
449
-
450
- def set_tag_container(tag_container)
451
- @tag_container = tag_container
452
- end
453
-
454
- def <<(tags, options = {})
455
- @tag_container.tag(tags, options.merge(:clear => false))
456
- end
457
-
458
- alias_method :concat, :<<
459
- end
460
-
461
- end
462
- end
463
- end
464
-
465
- ActiveRecord::Base.class_eval do
466
- include ActiveRecord::Acts::Taggable
467
- end
1
+ require 'active_support'
2
+ require 'active_record'
3
+
4
+ module ActiveRecord
5
+ module Acts #:nodoc:
6
+ module Taggable #:nodoc:
7
+
8
+ def self.append_features(base)
9
+ super
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ def self.split_tag_names(tags, separator,normalizer)
14
+ tag_names = []
15
+ if tags.is_a?(Array)
16
+ tag_names << tags
17
+ elsif tags.is_a?(String)
18
+ tag_names << (separator.is_a?(Proc) ? separator.call(tags) : tags.split(separator))
19
+ end
20
+ tag_names = tag_names.flatten.map { |name| normalizer.call(name.strip) }.uniq.compact #straight 'em up
21
+ end
22
+
23
+ # This mixin provides an easy way for adding tagging capabilities (also
24
+ # known as folksnomy) to your active record objects. It allows you to add
25
+ # tags to your objects as well as search for tagged objects.
26
+ #
27
+ # It assumes you are using a fully-normalized tagging database schema. For
28
+ # that, you need a table (by default, named +tags+) to hold all tags in your
29
+ # application and this table must have a primary key (normally a +id+ int
30
+ # autonumber column) and a +name+ varchar column. You must also define a model class
31
+ # related to this table (by default, named +Tag+).
32
+ #
33
+ # All tag names will be stored in this tags table. Taggable objects should reside
34
+ # in their own tables, like any other object. Tagging objects is performed by
35
+ # the +acts_as_taggable+ mixin using a +has_and_belong_to_many+ relationship that is
36
+ # automatically created on the taggable class, and as so, a join table must exist
37
+ # between the tags table and the taggable object table.
38
+ #
39
+ # The name of the join table follows the standards for rails
40
+ #
41
+ # Unless the join table is explicitly specified as an option,
42
+ # it is guessed using the lexical order of the class names.
43
+ #
44
+ # The join table must be composed of the foreign keys from the tags table and the
45
+ # taggable object table, so for instance, if we have a tags table named +tags+ (related
46
+ # to a +Tag+ model) and a taggable +photos+ table (related to a +Photo+ model),
47
+ # there should be a join table +tags_photos+ with int FK columns +photo_id+ and +tag_id+.
48
+ # If you dont use a explicit full model related to the join table (through the
49
+ # +:join_class_name+ option), you must not add a primary key to the join table.
50
+ #
51
+ # The +acts_as_taggable+ adds the instance methods +tag+, +tag_names+,
52
+ # +tag_names= +, +tag_names<< +, +tagged_with? + for adding tags to the object
53
+ # and also the class method +find_tagged_with+ method for search tagged objects.
54
+ #
55
+ # Examples:
56
+ #
57
+ # class Photo < ActiveRecord::Base
58
+ # # this creates a 'tags' collection, through a has_and_belongs_to_many
59
+ # # relationship that utilizes the join table 'photos_tags'.
60
+ # acts_as_taggable :normalizer => Proc.new {|name| name.downcase}
61
+ # end
62
+ #
63
+ # photo = Photo.new
64
+ #
65
+ # # splits and adds to the tags collection
66
+ # photo.tag "wine beer alcohol"
67
+ #
68
+ # # don't need to split since it's an array, but replaces the tags collection
69
+ # # trailing and leading spaces are properly removed
70
+ # photo.tag [ 'wine ', ' vodka'], :clear => true
71
+ #
72
+ # photo.tag_names # => [ 'wine', 'vodka' ]
73
+ # # You can remove tags one at a time or in a group
74
+ # photo.tag_remove 'wine'
75
+ # photo.tag_remove 'wine beer alcohol'
76
+ #
77
+ # # appends new tags with a different separator
78
+ # # the 'wine' tag wont be duplicated
79
+ # photo.tag_names << 'wine, beer, alcohol', :separator => ','
80
+ #
81
+ # # The difference between +tag_names+ and +tags+ is that +tag_names+
82
+ # # holds an array of String objects, mapped from +tags+, while +tags+
83
+ # # holds the actual +has_and_belongs_to_many+ collection, and so, is
84
+ # # composed of +Tag+ objects.
85
+ # photo.tag_names.size # => 4
86
+ # photo.tags.size # => 4
87
+ # # Now you can clear all tags in one call
88
+ # photo.clear_tags!
89
+ #
90
+ # # Find photos with 'wine' OR 'whisky'
91
+ # Photo.find_tagged_with :any => [ 'wine', 'whisky' ]
92
+ #
93
+ # # Finds photos with 'wine' AND 'whisky' using a different separator.
94
+ # # This is also known as tag combos.
95
+ # Photo.find_tagged_with(:all => 'wine+whisky', :separator => '+'
96
+ #
97
+ # # Gets the top 10 tags for all photos
98
+ # Photo.tags_count :limit => 10 # => { 'beer' => 68, 'wine' => 37, 'vodka' => '22', ... }
99
+ #
100
+ # # Gets the tags count that are greater than 30
101
+ # Photo.tags_count :count => '> 30' # => { 'beer' => 68, 'wine' => 37 }
102
+ #
103
+ # # Replace allows you to find_tagged_with, remove the old tags and add the new ones
104
+ # Photo.replace_tag("beer whisky","wine vodka")
105
+ # # Display the photos returned from the tags_count call using 9 different CSS classes
106
+ # <% Photo.cloud(@photo_tags, %w(cloud1 cloud2 cloud3 cloud4 cloud5 cloud6 cloud7 cloud8 cloud9)) do |tag, cloud_class| %>
107
+ # <%= link_to(h("<#{tag}>"), tag_photos_url(:name => tag), { :class => cloud_class } ) -%>
108
+ # <% end %>
109
+ #
110
+ # # Display the photos returned from the tags_count call using 5 different font sizes
111
+ # <% Photo.cloud(@photo_tags, %w(x-small small medium large x-large)) do |tag, font_size| %>
112
+ # <%= link_to(h("<#{tag}>"), tag_photos_url(:name => tag), { style: => "font-size: #{font_size}" } ) -%>
113
+ # <% end %>
114
+ #
115
+ # You can also use full join models if you want to take advantage of
116
+ # ActiveRecords callbacks, timestamping, inheritance and other features
117
+ # on the join records as well. For that, you use the +:join_class_name+ option.
118
+ # In this case, the join table must have a primary key.
119
+ #
120
+ # class Person
121
+ # # This defines a class +TagPerson+ automagically.
122
+ # acts_as_taggable :join_class_name => 'TagPerson'
123
+ # end
124
+ #
125
+ # # We can open the +TagPerson+ class and add features to it.
126
+ # class TagPerson
127
+ # acts_as_list :scope => :person
128
+ # belongs_to :created_by, :class_name => 'User', :foreign_key => 'created_by_id'
129
+ # before_save :do_some_validation
130
+ # after_save :do_some_stats
131
+ # end
132
+ #
133
+ # # We can do some interesting things with it now
134
+ # person = Person.new
135
+ # person.tag "wine beer alcohol", :attributes => { :created_by_id => 1 }
136
+ # Person.find_tagged_with(:any => 'wine', :condition => "tags_people.created_by_id = 1 AND tags_people.position = 1")
137
+ module ClassMethods
138
+
139
+ # This method defines a +has_and_belongs_to_many+ relationship between
140
+ # the target class and the tag model class. It also adds several instance methods
141
+ # for tagging objects of the target class, as well as a class method for searching
142
+ # objects that contains specific tags.
143
+ #
144
+ # The options are:
145
+ #
146
+ # The +:collection+ parameter receives a symbol defining
147
+ # the name of the tag collection method and it defaults to +:tags+.
148
+ #
149
+ # The +:tag_class_name+ parameter receives the tag model class name and
150
+ # it defaults to +'Tag'+.
151
+ #
152
+ # The +:tag_class_column_name+ parameter receives the tag model class name attribute and
153
+ # it defaults to +'name'+.
154
+ #
155
+ # The +:normalizer + paramater takes a Procs. This is used to normalize all tags
156
+ # Simple example
157
+ # :normalizer => Proc.new {|name| name.capitalize}
158
+ #
159
+ # The +:join_class_name+ parameter receives the model class name that joins
160
+ # the tag model and the taggable model. This automagically defines the join model
161
+ # class that can be opened and extended.
162
+ #
163
+ # The remaining options are passed on to the +has_and_belongs_to_many+ declaration.
164
+ # The +:join_table+ parameter is defined by default using the standard +has_and_belongs_to_many+ behavior.
165
+ def acts_as_taggable(options = {})
166
+
167
+ options = { :collection => :tags, :tag_class_name => 'Tag', :tag_class_column_name => 'name', :normalizer=> Proc.new {|name| name}}.merge(options)
168
+ collection_name = options[:collection]
169
+ tag_model = options[:tag_class_name].constantize
170
+ tag_model_name = options[:tag_class_column_name]
171
+ normalizer = options[:normalizer]
172
+ if tag_model.table_name < self.table_name
173
+ default_join_table = "#{tag_model.table_name}_#{self.table_name}"
174
+ else
175
+ default_join_table = "#{self.table_name}_#{tag_model.table_name}"
176
+ end
177
+ options[:join_table] ||= default_join_table
178
+ options[:foreign_key] ||= self.name.to_s.foreign_key
179
+ options[:association_foreign_key] ||= tag_model.to_s.foreign_key
180
+
181
+ # not using a simple has_and_belongs_to_many but a full model
182
+ # for joining the tags table and the taggable object table
183
+ if join_class_name = options[:join_class_name]
184
+ Object.class_eval "class #{join_class_name} < ActiveRecord::Base; set_table_name '#{options[:join_table]}' end" unless Object.const_defined?(join_class_name)
185
+
186
+ join_model = join_class_name.constantize
187
+ tagged = self
188
+ join_model.class_eval do
189
+ belongs_to :tag, :class_name => tag_model.to_s
190
+ belongs_to :tagged, :class_name => tagged.name.to_s
191
+ define_method(:normalizer, normalizer)
192
+ define_method(tag_model_name.to_sym) { self[tag_model_name] ||= normalizer(tag.send(tag_model_name.to_sym)) }
193
+ end
194
+
195
+
196
+ options[:class_name] ||= join_model.to_s
197
+ tag_pk, tag_fk = tag_model.primary_key, options[:association_foreign_key]
198
+ t, tn, jt = tag_model.table_name, tag_model_name, join_model.table_name
199
+ options[:finder_sql] ||= "SELECT #{jt}.*, #{t}.#{tn} AS #{tn} FROM #{jt}, #{t} WHERE #{jt}.#{tag_fk} = #{t}.#{tag_pk} AND #{jt}.#{options[:foreign_key]} = \#{quoted_id}"
200
+ else
201
+ join_model = nil
202
+ end
203
+
204
+ # set some class-wide attributes needed in class and instance methods
205
+ write_inheritable_attribute(:tag_foreign_key, options[:association_foreign_key])
206
+ write_inheritable_attribute(:taggable_foreign_key, options[:foreign_key])
207
+ write_inheritable_attribute(:normalizer, normalizer)
208
+ write_inheritable_attribute(:tag_collection_name, collection_name)
209
+ write_inheritable_attribute(:tag_model, tag_model)
210
+ write_inheritable_attribute(:tag_model_name, tag_model_name)
211
+ write_inheritable_attribute(:tags_join_model, join_model)
212
+ write_inheritable_attribute(:tags_join_table, options[:join_table])
213
+ write_inheritable_attribute(:tag_options, options)
214
+
215
+ [ :collection, :tag_class_name, :tag_class_column_name, :join_class_name,:normalizer].each { |key| options.delete(key) } # remove these, we don't need it anymore
216
+ [ :join_table, :association_foreign_key ].each { |key| options.delete(key) } if join_model # dont need this for has_many
217
+
218
+ # now, finally add the proper relationships
219
+ class_eval do
220
+ include ActiveRecord::Acts::Taggable::InstanceMethods
221
+ extend ActiveRecord::Acts::Taggable::SingletonMethods
222
+
223
+ class_inheritable_reader :tag_collection_name, :tag_model, :tag_model_name, :tags_join_model,
224
+ :tags_options, :tags_join_table,
225
+ :tag_foreign_key, :taggable_foreign_key,:normalizer
226
+ if join_model
227
+ has_many collection_name, options
228
+ else
229
+ has_and_belongs_to_many collection_name, options
230
+ end
231
+ end
232
+
233
+ end
234
+ end
235
+
236
+ module SingletonMethods
237
+ # This method searches for objects of the taggable class and subclasses that
238
+ # contains specific tags associated to them. The tags to be searched for can
239
+ # be passed to the +:any+ or +:all+ options, either as a String or an Array.
240
+ #
241
+ # The options are:
242
+ #
243
+ # +:any+: searches objects that are related to ANY of the given tags
244
+ #
245
+ # +:all+: searcher objects that are related to ALL of the given tags
246
+ #
247
+ # +:separator+: a string, regex or Proc object that will be used to split the
248
+ # tags string passed to +:any+ or +:all+ using a regular +String#split+ method.
249
+ # If a Proc is passed, the proc should split the string in any way it wants
250
+ # and return an array of strings.
251
+ #
252
+ # +:conditions+: any additional conditions that should be appended to the
253
+ # WHERE clause of the finder SQL. Just like regular +ActiveRecord::Base#find+ methods.
254
+ #
255
+ # +:order+: the same as used in regular +ActiveRecord::Base#find+ methods.
256
+ #
257
+ # +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
258
+ def find_tagged_with(options = {})
259
+ options = { :separator => ' ' }.merge(options)
260
+
261
+ tag_names = ActiveRecord::Acts::Taggable.split_tag_names(options[:any] || options[:all], options[:separator], normalizer)
262
+ raise "No tags were passed to :any or :all options" if tag_names.empty?
263
+
264
+ o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
265
+ sql = "SELECT #{o}.* FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
266
+ AND (#{t}.#{tn} = '#{tag_names.join("' OR #{t}.#{tn}='")}')
267
+ AND #{o}.#{o_pk} = #{jt}.#{o_fk}"
268
+ sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
269
+ sql << " GROUP BY #{o}.#{o_pk}"
270
+ sql << " HAVING COUNT(#{o}.#{o_pk}) = #{tag_names.length}" if options[:all]
271
+ sql << " ORDER BY #{options[:order]} " if options[:order]
272
+ add_limit!(sql, options)
273
+
274
+ find_by_sql(sql)
275
+ end
276
+ #Looks for items with and old_tag and replaces it with all of new_tag
277
+ # The +old_tag+ ,+new_tag+ parameters can be a +String+, +Array+ or a +Proc+ object.
278
+ # If it's a +String+, it's split using the +:separator+ specified in
279
+ # the +options+ hash. If it's an +Array+ it is flattened and compacted.
280
+ # Duplicate entries will be removed as well. Tag names are also stripped
281
+ # of trailing and leading whitespace. If a Proc is passed,
282
+ # the proc should split the string in any way it wants and return an array of strings.
283
+ #
284
+ # The +options+ hash has the following parameters:
285
+ #
286
+ # +:separator+: a string, regex or Proc object that will be used to split the
287
+ # tags string passed to +:any+ or +:all+ using a regular +String#split+ method.
288
+ # If a Proc is passed, the proc should split the string in any way it wants
289
+ # and return an array of strings.
290
+ #
291
+ # +:conditions+: any additional conditions that should be appended to the
292
+ # WHERE clause of the finder SQL. Just like regular +ActiveRecord::Base#find+ methods.
293
+ #
294
+ def replace_tag(old_tag,new_tag,options = {})
295
+
296
+ options = { :any => old_tag ,:separator => ' ', :conditions => nil }.merge(options)
297
+ find_tagged_with(options).each do |item|
298
+ item.tag_remove(old_tag)
299
+ item.tag(new_tag, :separator => options[:separator])
300
+ end
301
+ end
302
+
303
+ # This method counts the number of times the tags have been applied to your objects
304
+ # and, by default, returns a hash in the form of { 'tag_name' => count, ... }
305
+ #
306
+ # The options are:
307
+ #
308
+ # +:raw+: If you just want to get the raw output of the SQL statement (Array of Hashes), instead of the regular tags count Hash, set this to +true+.
309
+ #
310
+ # +:conditions+: any additional conditions that should be appended to the
311
+ # WHERE clause of the SQL. Just like in regular +ActiveRecord::Base#find+ methods.
312
+ #
313
+ # +:order+: The same as used in +ActiveRecord::Base#find+ methods. By default, this is 'count DESC'.
314
+ #
315
+ # +:count+: Adds a HAVING clause to the SQL statement, where you can set conditions for the 'count' column. For example: '> 50'
316
+ #
317
+ # +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
318
+ def tags_count(options = {})
319
+ options = {:order => 'count DESC'}.merge(options)
320
+
321
+ o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
322
+ sql = "SELECT #{t}.#{t_pk} AS id, #{t}.#{tn} AS name, COUNT(*) AS count FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
323
+ AND #{jt}.#{o_fk} = #{o}.#{o_pk}"
324
+ sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
325
+ sql << " GROUP BY #{t}.#{tn}"
326
+ sql << " HAVING count #{options[:count]} " if options[:count]
327
+ sql << " ORDER BY #{options[:order]} " if options[:order]
328
+ add_limit!(sql, options)
329
+ result = connection.select_all(sql)
330
+ count = result.inject({}) { |hsh, row| hsh[row["#{tn}"]] = row['count'].to_i; hsh } unless options[:raw]
331
+
332
+ count || result
333
+ end
334
+ #This method returns a simple count of the number of distinct objects
335
+ #Which match the tags provided
336
+ # by Lon Baker
337
+ def count_uniq_tagged_with(options = {})
338
+ options = { :separator => ' ' }.merge(options)
339
+
340
+ tag_names = ActiveRecord::Acts::Taggable.split_tag_names(options[:any] || options[:all], options[:separator], normalizer)
341
+ raise "No tags were passed to :any or :all options" if tag_names.empty?
342
+
343
+ o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
344
+ sql = "SELECT COUNT(DISTINCT #{o}.#{o_pk}) FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
345
+ AND (#{t}.#{tn} = '#{tag_names.join("' OR #{t}.#{tn} ='")}')
346
+ AND #{o}.#{o_pk} = #{jt}.#{o_fk}"
347
+ sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
348
+ count_by_sql(sql)
349
+ end
350
+
351
+ # Alias for +tags_count+
352
+ alias_method :tag_count, :tags_count
353
+
354
+ # Finds other records that share the most tags with the record passed
355
+ # as the +related+ parameter. Useful for constructing 'Related' or
356
+ # 'See Also' boxes and lists.
357
+ #
358
+ # The options are:
359
+ #
360
+ # +:limit+: defaults to 5, which means the method will return the top 5 records
361
+ # that share the greatest number of tags with the passed one.
362
+ # +:conditions+: any additional conditions that should be appended to the
363
+ # WHERE clause of the finder SQL. Just like regular +ActiveRecord::Base#find+ methods.
364
+ def find_related_tagged(related, options = {})
365
+ related_id = related.is_a?(self) ? related.id : related
366
+ options = { :limit => 5 }.merge(options)
367
+
368
+ o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
369
+ sql = "SELECT o.*, COUNT(jt2.#{o_fk}) AS count FROM #{o} o, #{jt} jt, #{t} t, #{jt} jt2
370
+ WHERE jt.#{o_fk}=#{related_id} AND t.#{t_pk} = jt.#{t_fk}
371
+ AND jt2.#{o_fk} != jt.#{o_fk}
372
+ AND jt2.#{t_fk}=jt.#{t_fk} AND o.#{o_pk} = jt2.#{o_fk}"
373
+ sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
374
+ sql << " GROUP BY #{o}.#{o_pk}"
375
+ sql << " ORDER BY count DESC"
376
+ add_limit!(sql, options)
377
+
378
+ find_by_sql(sql)
379
+ end
380
+
381
+ # Finds other tags that are related to the tags passed through the +tags+
382
+ # parameter, by finding common records that share similar sets of tags.
383
+ # Useful for constructing 'Related tags' lists.
384
+ #
385
+ # The options are:
386
+ #
387
+ # +:separator+ => defines the separator (String or Regex) used to split
388
+ # the tags parameter and defaults to ' ' (space and line breaks).
389
+ #
390
+ # +:raw+: If you just want to get the raw output of the SQL statement (Array of Hashes), instead of the regular tags count Hash, set this to +true+.
391
+ #
392
+ # +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
393
+ def find_related_tags(tags, options = {})
394
+ tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator], normalizer)
395
+ o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
396
+
397
+ sql = "SELECT jt.#{o_fk} AS o_id FROM #{jt} jt, #{t} t
398
+ WHERE jt.#{t_fk} = t.#{t_pk}
399
+ AND (t.#{tn} IN ('#{tag_names.uniq.join("', '")}'))
400
+ GROUP BY jt.#{o_fk}
401
+ HAVING COUNT(jt.#{o_fk})=#{tag_names.length}"
402
+
403
+ o_ids = connection.select_all(sql).map { |row| row['o_id'] }
404
+ return options[:raw] ? [] : {} if o_ids.length < 1
405
+
406
+ sql = "SELECT t.#{t_pk} AS id, t.#{n} AS #{tn}, COUNT(jt.#{o_fk}) AS count FROM #{jt} jt, #{t} t
407
+ WHERE jt.#{o_fk} IN (#{o_ids.join(",")})
408
+ AND t.#{t_pk} = jt.#{t_fk}
409
+ GROUP BY jt.#{t_fk}
410
+ ORDER BY count DESC"
411
+ add_limit!(sql, options)
412
+
413
+ result = connection.select_all(sql).delete_if { |row| tag_names.include?(row["#{tn}"]) }
414
+ count = result.inject({}) { |hsh, row| hsh[row["#{tn}"]] = row['count'].to_i; hsh } unless options[:raw]
415
+
416
+ count || result
417
+ end
418
+
419
+ # Takes the result of a tags_count call and an array of categories and
420
+ # distributes the entries in the tags_count hash evenly across the
421
+ # categories based on the count value for each tag.
422
+ #
423
+ # Typically, this is used to display a 'tag cloud' in your UI.
424
+ #
425
+ # The options are:
426
+ #
427
+ # +tag_hash+ => The tag hash returned from a tags_count call
428
+ #
429
+ # +category_list+ => An array containing the categories to split the tags
430
+ # into
431
+ #
432
+ # +block+ => { |tag, category| }
433
+ #
434
+ # The block parameters are:
435
+ #
436
+ # +:tag+ => The tag key from the tag_hash
437
+ #
438
+ # +:category+ => The category value from the category_list that this tag
439
+ # is in
440
+ def cloud(tag_hash, category_list)
441
+ max, min = 0, 0
442
+ tag_hash.each_value do |count|
443
+ max = count if count > max
444
+ min = count if count < min
445
+ end
446
+
447
+ divisor = ((max - min) / category_list.size) + 1
448
+
449
+ tag_hash.each do |tag, count|
450
+ yield tag, category_list[(count - min) / divisor]
451
+ end
452
+ end
453
+
454
+ private
455
+ def set_locals_for_sql
456
+ [ table_name, primary_key, taggable_foreign_key,
457
+ tag_model.table_name, tag_model_name, tag_model.primary_key, tag_foreign_key,
458
+ tags_join_model ? tags_join_model.table_name : tags_join_table ]
459
+ end
460
+
461
+ end
462
+
463
+ module InstanceMethods
464
+ # Handles clearing all associated tags
465
+ def clear_tags!
466
+ tag_collection.clear
467
+ end
468
+ # This method removes tags from the target object, by parsing the tags parameter
469
+ # into Tag object instances and removing them from the tag collection of the object if they exist.
470
+ #
471
+ # The +tags+ parameter can be a +String+, +Array+ or a +Proc+ object.
472
+ # If it's a +String+, it's split using the +:separator+ specified in
473
+ # the +options+ hash. If it's an +Array+ it is flattened and compacted.
474
+ # Duplicate entries will be removed as well. Tag names are also stripped
475
+ # of trailing and leading whitespace. If a Proc is passed,
476
+ # the proc should split the string in any way it wants and return an array of strings.
477
+ #
478
+ # The +options+ hash has the following parameters:
479
+ #
480
+ # +:separator+ => defines the separator (String or Regex) used to split
481
+ # the tags parameter and defaults to ' ' (space and line breaks).
482
+ def tag_remove(tags, options = {})
483
+
484
+ options = { :separator => ' '}.merge(options)
485
+ attributes = options[:attributes] || {}
486
+
487
+ # parse the tags parameter
488
+ tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator], normalizer)
489
+
490
+ # remove the tag names to the collection
491
+ tag_names.each do |name|
492
+ tag_record = tag_model.find(:first, :conditions=>["#{tag_model_name} = ?",name]) || tag_model.new(tag_model_name.to_sym => name)
493
+ if tag_record
494
+ tag_collection.delete(tag_record)
495
+ end
496
+ end
497
+ end
498
+
499
+ # This method applies tags to the target object, by parsing the tags parameter
500
+ # into Tag object instances and adding them to the tag collection of the object.
501
+ # If the tag name already exists in the tags table, it just adds a relationship
502
+ # to the existing tag record. If it doesn't exist, it then creates a new
503
+ # Tag record for it.
504
+ #
505
+ # The +tags+ parameter can be a +String+, +Array+ or a +Proc+ object.
506
+ # If it's a +String+, it's split using the +:separator+ specified in
507
+ # the +options+ hash. If it's an +Array+ it is flattened and compacted.
508
+ # Duplicate entries will be removed as well. Tag names are also stripped
509
+ # of trailing and leading whitespace. If a Proc is passed,
510
+ # the proc should split the string in any way it wants and return an array of strings.
511
+ #
512
+ # The +options+ hash has the following parameters:
513
+ #
514
+ # +:separator+ => defines the separator (String or Regex) used to split
515
+ # the tags parameter and defaults to ' ' (space and line breaks).
516
+ #
517
+ # +:clear+ => defines whether the existing tag collection will be cleared before
518
+ # applying the new +tags+ passed. Defaults to +false+.
519
+ def tag(tags, options = {})
520
+
521
+ options = { :separator => ' ', :clear => false }.merge(options)
522
+ attributes = options[:attributes] || {}
523
+
524
+ # parse the tags parameter
525
+ tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator], normalizer)
526
+
527
+ # clear the collection if appropriate
528
+ self.clear_tags! if options[:clear]
529
+
530
+ # append the tag names to the collection
531
+ tag_names.each do |name|
532
+ # ensure that tag names don't get duplicated
533
+ tag_record = tag_model.find(:first, :conditions=>["#{tag_model_name} = ?",name]) || tag_model.new(tag_model_name.to_sym => name)
534
+ if tags_join_model
535
+ tag_join_record = tags_join_model.new(attributes)
536
+ tag_join_record.tag = tag_record
537
+ tag_join_record.tagged = self
538
+ tag_collection << tag_join_record unless tagged_with?(name)
539
+ else
540
+ tag_collection.push_with_attributes(tag_record, attributes) unless tagged_with?(name)
541
+ end
542
+ end
543
+
544
+ end
545
+
546
+ # Clears the current tags collection and sets the tag names for this object.
547
+ # Equivalent of calling #tag(..., :clear => true)
548
+ #
549
+ # Another way of appending tags to a existing tags collection is by using
550
+ # the +<<+ or +concat+ method on +tag_names+, which is equivalent of calling
551
+ # #tag(..., :clear => false).
552
+ def tag_names=(tags, options = {})
553
+ tag(tags, options.merge(:clear => true))
554
+ end
555
+
556
+ # Returns an array of strings containing the tags applied to this object.
557
+ # If +reload+ is +true+, the tags collection is reloaded.
558
+ def tag_names(reload = false)
559
+ ary = tag_collection(reload).map { |tag| tag.send(tag_model_name.to_sym)}
560
+ ary.extend(TagNamesMixin)
561
+ ary.set_tag_container(self)
562
+ ary
563
+ end
564
+
565
+ # Checks to see if this object has been tagged with +tag_name+.
566
+ # If +reload+ is true, reloads the tag collection before doing the check.
567
+ def tagged_with?(tag_name, reload = false)
568
+ tag_names(reload).include?(tag_name)
569
+ end
570
+
571
+ # Calls +find_related_tagged+ passing +self+ as the +related+ parameter.
572
+ def tagged_related(options = {})
573
+ self.class.find_related_tagged(self.id, options)
574
+ end
575
+
576
+ private
577
+ def tag_model
578
+ self.class.tag_model
579
+ end
580
+
581
+ def tag_collection(reload = false)
582
+ send(self.class.tag_collection_name, reload)
583
+ end
584
+
585
+ def tags_join_model
586
+ self.class.tags_join_model
587
+ end
588
+
589
+ end
590
+
591
+ module TagNamesMixin #:nodoc:
592
+
593
+ def set_tag_container(tag_container)
594
+ @tag_container = tag_container
595
+ end
596
+
597
+ def <<(tags, options = {})
598
+ @tag_container.tag(tags, options.merge(:clear => false))
599
+ end
600
+
601
+ alias_method :concat, :<<
602
+ end
603
+
604
+ end
605
+ end
606
+ end
607
+
608
+ ActiveRecord::Base.class_eval do
609
+ include ActiveRecord::Acts::Taggable
610
+ end