acts_as_taggable 1.0.4
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.
- data/README +70 -0
- data/lib/taggable.rb +467 -0
- data/test/acts_as_taggable_test.rb +384 -0
- data/test/debug.log +28508 -0
- metadata +64 -0
data/README
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
= The Acts As Taggable Mixin
|
|
2
|
+
|
|
3
|
+
== Installation
|
|
4
|
+
|
|
5
|
+
To install or update the gem, simply execute:
|
|
6
|
+
|
|
7
|
+
gem install acts_as_taggable
|
|
8
|
+
|
|
9
|
+
To use the 'acts_as_taggable' library in your Rails application after installing
|
|
10
|
+
the gem, add this line at the end of your 'config/environment.rb' file:
|
|
11
|
+
|
|
12
|
+
require_gem 'acts_as_taggable'
|
|
13
|
+
|
|
14
|
+
== Usage Instructions
|
|
15
|
+
|
|
16
|
+
To use the acts_as_taggable mixin with your ActiveRecord objects, you must use
|
|
17
|
+
a normalized database schema for tagging (also know as folksnomies).
|
|
18
|
+
|
|
19
|
+
This means that you must have a table solely for holding tag names. Usually,
|
|
20
|
+
this table is named 'tags' having at least 2 columns: primary key (usually
|
|
21
|
+
an autoincrement integer called 'id' - the AR standard for PKs) and a 'name'
|
|
22
|
+
columns, usually a varchar. You must also have a defined ActiveRecord model
|
|
23
|
+
class that relates to this table, by default called 'Tag'.
|
|
24
|
+
|
|
25
|
+
For associating tags to your objects you also must have join tables that are
|
|
26
|
+
composed of at least 2 columns: the tags table foreign key (by default 'tag_id')
|
|
27
|
+
and your taggable object table foreign key.
|
|
28
|
+
|
|
29
|
+
If you�re using the simple has_and_belongs_to_many model, you must NOT have a
|
|
30
|
+
primary key (usually an 'id' column) defined on the join table. If you�re using
|
|
31
|
+
a full join model, you must add a primary key column to the join table. Please
|
|
32
|
+
see the RDoc documentation on acts_as_taggable macro and the :join_class_name
|
|
33
|
+
option for the differences between these two approaches.
|
|
34
|
+
|
|
35
|
+
For example, suppose you are tagging photos and you hold your photo data thru
|
|
36
|
+
the Photo model and on the 'photos' table. Your database schema would look
|
|
37
|
+
something like this (example suited for MySQL):
|
|
38
|
+
|
|
39
|
+
CREATE TABLE `tags` (
|
|
40
|
+
`id` int(11) NOT NULL auto_increment,
|
|
41
|
+
`name` varchar(255) default NULL,
|
|
42
|
+
PRIMARY KEY (`id`)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
CREATE TABLE `tags_photos` (
|
|
46
|
+
`tag_id` int(11) NOT NULL,
|
|
47
|
+
`photo_id` int(11) NOT NULL
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
CREATE TABLE `photos` (
|
|
51
|
+
`id` int(11) NOT NULL auto_increment,
|
|
52
|
+
`title` varchar(255) default NULL,
|
|
53
|
+
`author_name` varchar(255) default NULL,
|
|
54
|
+
`image_path` varchar(255) default NULL,
|
|
55
|
+
PRIMARY KEY (`id`)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
You would normally define 2 models to relate to these tables:
|
|
59
|
+
|
|
60
|
+
class Tag < ActiveRecord::Base
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class Photo < ActiveRecord::Base
|
|
64
|
+
acts_as_taggable
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Now you can easily apply and search for tags on photos in your Rails application.
|
|
68
|
+
|
|
69
|
+
This assumes you�re using only default naming conventions. For using the mix-in
|
|
70
|
+
with non-standard naming conventions, please see the proper RDoc documentation.
|
data/lib/taggable.rb
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
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
|