acts_as_taggable 1.0.4 → 2.0.0

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