tagtical 1.0.6
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +25 -0
- data/Gemfile +20 -0
- data/Gemfile.lock +25 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +306 -0
- data/Rakefile +59 -0
- data/VERSION +1 -0
- data/generators/tagtical_migration/tagtical_migration_generator.rb +7 -0
- data/generators/tagtical_migration/templates/migration.rb +34 -0
- data/lib/generators/tagtical/migration/migration_generator.rb +32 -0
- data/lib/generators/tagtical/migration/templates/active_record/migration.rb +35 -0
- data/lib/tagtical/acts_as_tagger.rb +69 -0
- data/lib/tagtical/compatibility/Gemfile +8 -0
- data/lib/tagtical/compatibility/active_record_backports.rb +21 -0
- data/lib/tagtical/tag.rb +314 -0
- data/lib/tagtical/tag_list.rb +133 -0
- data/lib/tagtical/taggable/cache.rb +53 -0
- data/lib/tagtical/taggable/collection.rb +141 -0
- data/lib/tagtical/taggable/core.rb +317 -0
- data/lib/tagtical/taggable/ownership.rb +110 -0
- data/lib/tagtical/taggable/related.rb +60 -0
- data/lib/tagtical/taggable.rb +51 -0
- data/lib/tagtical/tagging.rb +42 -0
- data/lib/tagtical/tags_helper.rb +17 -0
- data/lib/tagtical.rb +47 -0
- data/rails/init.rb +1 -0
- data/spec/bm.rb +53 -0
- data/spec/database.yml +17 -0
- data/spec/database.yml.sample +17 -0
- data/spec/models.rb +60 -0
- data/spec/schema.rb +46 -0
- data/spec/spec_helper.rb +159 -0
- data/spec/tagtical/acts_as_tagger_spec.rb +94 -0
- data/spec/tagtical/tag_list_spec.rb +102 -0
- data/spec/tagtical/tag_spec.rb +301 -0
- data/spec/tagtical/taggable_spec.rb +460 -0
- data/spec/tagtical/tagger_spec.rb +76 -0
- data/spec/tagtical/tagging_spec.rb +52 -0
- data/spec/tagtical/tags_helper_spec.rb +28 -0
- data/spec/tagtical/tagtical_spec.rb +340 -0
- metadata +132 -0
data/lib/tagtical/tag.rb
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
module Tagtical
|
2
|
+
class Tag < ::ActiveRecord::Base
|
3
|
+
|
4
|
+
attr_accessible :value
|
5
|
+
|
6
|
+
### ASSOCIATIONS:
|
7
|
+
|
8
|
+
has_many :taggings, :dependent => :destroy, :class_name => 'Tagtical::Tagging'
|
9
|
+
|
10
|
+
### VALIDATIONS:
|
11
|
+
|
12
|
+
validates :value, :uniqueness => {:scope => :type}, :presence => true # type is not required, it can be blank
|
13
|
+
|
14
|
+
## POSSIBLE_VALUES SUPPORT:
|
15
|
+
|
16
|
+
class_attribute :possible_values
|
17
|
+
validate :validate_possible_values
|
18
|
+
|
19
|
+
self.store_full_sti_class = false
|
20
|
+
|
21
|
+
### CLASS METHODS:
|
22
|
+
|
23
|
+
class << self
|
24
|
+
|
25
|
+
def where_any(list, options={})
|
26
|
+
char = "%" if options[:wildcard]
|
27
|
+
operator = options[:case_insensitive] || options[:wildcard] ?
|
28
|
+
(using_postgresql? ? 'ILIKE' : 'LIKE') :
|
29
|
+
"="
|
30
|
+
conditions = Array(list).map { |tag| ["value #{operator} ?", "#{char}#{tag.to_s}#{char}"] }
|
31
|
+
where(conditions.size==1 ? conditions.first : conditions.map { |c| sanitize_sql(c) }.join(" OR "))
|
32
|
+
end
|
33
|
+
|
34
|
+
def using_postgresql?
|
35
|
+
connection.adapter_name=='PostgreSQL'
|
36
|
+
end
|
37
|
+
|
38
|
+
# Use this for case insensitive
|
39
|
+
def where_any_like(list, options={})
|
40
|
+
where_any(list, options.update(:case_insensitive => true))
|
41
|
+
end
|
42
|
+
|
43
|
+
### CLASS METHODS:
|
44
|
+
|
45
|
+
def find_or_create_with_like_by_value!(value)
|
46
|
+
where_any_like(value).first || create!(:value => value)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Method used to ensure list of tags for the given Tag class.
|
50
|
+
# Returns a hash with the key being the value from the tag list and the value being the saved tag.
|
51
|
+
def find_or_create_tags(*tag_list)
|
52
|
+
tag_list = [tag_list].flatten
|
53
|
+
return {} if tag_list.empty?
|
54
|
+
|
55
|
+
existing_tags = where_any_like(tag_list).all
|
56
|
+
tag_list.each_with_object({}) do |value, tag_lookup|
|
57
|
+
tag_lookup[detect_comparable(existing_tags, value) || create!(:value => value)] = value
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def sti_name
|
62
|
+
return @sti_name if instance_variable_defined?(:@sti_name)
|
63
|
+
@sti_name = Tagtical::Tag==self ? nil : Type.new(name.demodulize).to_sti_name
|
64
|
+
end
|
65
|
+
|
66
|
+
protected
|
67
|
+
|
68
|
+
def compute_type(type_name)
|
69
|
+
@@compute_type ||= {}
|
70
|
+
# super is required when it gets called from a reflection.
|
71
|
+
@@compute_type[type_name] || super
|
72
|
+
rescue Exception => e
|
73
|
+
@@compute_type[type_name] = Type.new(type_name).klass!
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# Checks to see if a tags value is present in a set of tags and returns that tag.
|
79
|
+
def detect_comparable(tags, value)
|
80
|
+
value = comparable_value(value)
|
81
|
+
tags.detect { |tag| comparable_value(tag.value) == value }
|
82
|
+
end
|
83
|
+
|
84
|
+
def comparable_value(str)
|
85
|
+
RUBY_VERSION >= "1.9" ? str.downcase : str.mb_chars.downcase
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
89
|
+
|
90
|
+
### INSTANCE METHODS:
|
91
|
+
|
92
|
+
def ==(object)
|
93
|
+
super || (object.is_a?(self.class) && value == object.value)
|
94
|
+
end
|
95
|
+
|
96
|
+
def relevance
|
97
|
+
(v = self["relevance"]) && v.to_f
|
98
|
+
end
|
99
|
+
|
100
|
+
# Try to sort by the relevance if provided.
|
101
|
+
def <=>(tag)
|
102
|
+
if (r1 = relevance) && (r2 = tag.relevance)
|
103
|
+
r1 <=> r2
|
104
|
+
else
|
105
|
+
value <=> tag.value
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def to_s
|
110
|
+
value
|
111
|
+
end
|
112
|
+
|
113
|
+
# Overwrite these methods to provide your own storage mechanism for a tag.
|
114
|
+
def load_value(value) value end
|
115
|
+
def dump_value(value) value end
|
116
|
+
|
117
|
+
def value
|
118
|
+
@value ||= load_value(self[:value])
|
119
|
+
end
|
120
|
+
|
121
|
+
def value=(value)
|
122
|
+
@value = nil
|
123
|
+
self[:value] = dump_value(value)
|
124
|
+
end
|
125
|
+
|
126
|
+
# We return nil if we are *not* an STI class.
|
127
|
+
def type
|
128
|
+
type = self[:type]
|
129
|
+
type && Type[type]
|
130
|
+
end
|
131
|
+
|
132
|
+
def count
|
133
|
+
self[:count].to_i
|
134
|
+
end
|
135
|
+
|
136
|
+
private
|
137
|
+
|
138
|
+
def validate_possible_values
|
139
|
+
if possible_values && !possible_values.include?(value)
|
140
|
+
errors.add(:value, %{Value "#{value}" not found in list: #{possible_values.inspect}})
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
class Type < String
|
145
|
+
|
146
|
+
# "tag" should always correspond with demodulize name of the base Tag class (ie Tagtical::Tag).
|
147
|
+
BASE = "tag".freeze
|
148
|
+
|
149
|
+
# Default to simply "tag", if none is provided. This will return Tagtical::Tag on calls to #klass
|
150
|
+
def initialize(arg)
|
151
|
+
super(arg.to_s.singularize.underscore.gsub(/_tag$/, ''))
|
152
|
+
end
|
153
|
+
|
154
|
+
class << self
|
155
|
+
def find(input)
|
156
|
+
return input.map { |c| self[c] } if input.is_a?(Array)
|
157
|
+
input.is_a?(self) ? input : new(input)
|
158
|
+
end
|
159
|
+
alias :[] :find
|
160
|
+
end
|
161
|
+
|
162
|
+
# The STI name for the Tag model is the same as the tag type.
|
163
|
+
def to_sti_name
|
164
|
+
self
|
165
|
+
end
|
166
|
+
|
167
|
+
# Leverages current type_condition logic from ActiveRecord while also allowing for type conditions
|
168
|
+
# when no Tag subclass is defined. Also, it builds the type condition for STI inheritance.
|
169
|
+
#
|
170
|
+
# Options:
|
171
|
+
# <tt>sql</tt> - Set to true to return sql string. Set to :append to return a sql string which can be appended as a condition.
|
172
|
+
# <tt>only</tt> - An array of the following: :parents, :current, :children. Will construct conditions to query the current, parent, and/or children STI classes.
|
173
|
+
#
|
174
|
+
def finder_type_condition(options={})
|
175
|
+
only = Array.wrap(options[:only] || (klass ? [:current, :children] : :current))
|
176
|
+
|
177
|
+
# If we want [:current, :children] or [:current, :children, :parents] and we don't need the finder type condition,
|
178
|
+
# then that means we don't need a condition at all since we are at the top-level sti class and we are essentially
|
179
|
+
# searching the whole range of sti classes.
|
180
|
+
if klass && !klass.finder_needs_type_condition?
|
181
|
+
only.delete(:parents) # we are at the topmost level.
|
182
|
+
only = [] if only==[:current, :children] # no condition is required if we want the current AND the children.
|
183
|
+
end
|
184
|
+
|
185
|
+
sti_names = []
|
186
|
+
if only.include?(:current)
|
187
|
+
sti_names << (klass ? klass.sti_name : to_sti_name)
|
188
|
+
end
|
189
|
+
if only.include?(:children) && klass
|
190
|
+
sti_names.concat(klass.descendants.map(&:sti_name))
|
191
|
+
end
|
192
|
+
if only.include?(:parents) && klass # include searches up the STI chain
|
193
|
+
parent_class = klass.superclass
|
194
|
+
while parent_class <= Tagtical::Tag
|
195
|
+
sti_names << parent_class.sti_name
|
196
|
+
parent_class = parent_class.superclass
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
sti_column = Tagtical::Tag.arel_table[Tagtical::Tag.inheritance_column]
|
201
|
+
condition = sti_names.inject(nil) do |conds, sti_name|
|
202
|
+
cond = sti_column.eq(sti_name)
|
203
|
+
conds.nil? ? cond : conds.or(cond)
|
204
|
+
end
|
205
|
+
|
206
|
+
if condition && options[:sql]
|
207
|
+
condition = condition.to_sql
|
208
|
+
condition.insert(0, " AND ") if options[:sql]==:append
|
209
|
+
end
|
210
|
+
condition
|
211
|
+
end
|
212
|
+
|
213
|
+
def scoping(options={}, &block)
|
214
|
+
finder_type_condition = finder_type_condition(options)
|
215
|
+
if block_given?
|
216
|
+
if finder_type_condition
|
217
|
+
Tagtical::Tag.send(:with_scope, :find => Tagtical::Tag.where(finder_type_condition), :create => {:type => self}) do
|
218
|
+
Tagtical::Tag.instance_exec(&block)
|
219
|
+
end
|
220
|
+
else
|
221
|
+
Tagtical::Tag.instance_exec(&block)
|
222
|
+
end
|
223
|
+
else
|
224
|
+
Tagtical::Tag.send(*(finder_type_condition ? [:where, finder_type_condition] : :unscoped))
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Return the Tag subclass
|
229
|
+
def klass
|
230
|
+
instance_variable_get(:@klass) || instance_variable_set(:@klass, find_tag_class)
|
231
|
+
end
|
232
|
+
|
233
|
+
# Return the Tag class or return top-level
|
234
|
+
def klass!
|
235
|
+
klass || Tagtical::Tag
|
236
|
+
end
|
237
|
+
|
238
|
+
def has_many_name
|
239
|
+
pluralize.to_sym
|
240
|
+
end
|
241
|
+
alias scope_name has_many_name
|
242
|
+
|
243
|
+
def base?
|
244
|
+
!!klass && klass.descends_from_active_record?
|
245
|
+
end
|
246
|
+
|
247
|
+
def ==(val)
|
248
|
+
super(self.class[val])
|
249
|
+
end
|
250
|
+
|
251
|
+
def tag_list_name(prefix=nil)
|
252
|
+
prefix = prefix.to_s.dup
|
253
|
+
prefix << "_" unless prefix.blank?
|
254
|
+
"#{prefix}#{self}_list"
|
255
|
+
end
|
256
|
+
|
257
|
+
def tag_list_ivar(*args)
|
258
|
+
"@#{tag_list_name(*args)}"
|
259
|
+
end
|
260
|
+
|
261
|
+
# Returns the level from which it extends from Tagtical::Tag
|
262
|
+
def active_record_sti_level
|
263
|
+
@active_record_sti_level ||= begin
|
264
|
+
count, current_class = 0, klass!
|
265
|
+
while !current_class.descends_from_active_record?
|
266
|
+
current_class = current_class.superclass
|
267
|
+
count += 1
|
268
|
+
end
|
269
|
+
count
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
private
|
274
|
+
|
275
|
+
# Returns an array of potential class names for this specific type.
|
276
|
+
def derive_class_candidates
|
277
|
+
[].tap do |arr|
|
278
|
+
[classify, "#{classify}Tag"].each do |name| # support Interest and InterestTag class names.
|
279
|
+
"Tagtical::Tag".tap do |longest_candidate|
|
280
|
+
longest_candidate << "::#{name}" unless name=="Tag"
|
281
|
+
end.scan(/^|::/) { arr << $' } # Klass, Tag::Klass, Tagtical::Tag::Klass
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
def find_tag_class
|
287
|
+
candidates = derive_class_candidates
|
288
|
+
|
289
|
+
# Attempt to find the preloaded class instead of having to do NameError catching below.
|
290
|
+
candidates.each do |candidate|
|
291
|
+
constants = ActiveSupport::Dependencies::Reference.send(:class_variable_get, :@@constants)
|
292
|
+
if constants.key?(candidate) && (constant = constants[candidate]) <= Tagtical::Tag # must check for key first, do not want to trigger default proc.
|
293
|
+
return constant
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
# Logic comes from ActiveRecord::Base#compute_type.
|
298
|
+
candidates.each do |candidate|
|
299
|
+
begin
|
300
|
+
constant = ActiveSupport::Dependencies.constantize(candidate)
|
301
|
+
return constant if candidate == constant.to_s && constant <= Tagtical::Tag
|
302
|
+
rescue NameError => e
|
303
|
+
# We don't want to swallow NoMethodError < NameError errors
|
304
|
+
raise e unless e.instance_of?(NameError)
|
305
|
+
rescue ArgumentError
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
nil
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
end
|
314
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
module Tagtical
|
2
|
+
class TagList < Array
|
3
|
+
class TagValue < String
|
4
|
+
attr_accessor :relevance
|
5
|
+
def initialize(value, relevance=nil)
|
6
|
+
@relevance = relevance
|
7
|
+
super(value.to_s)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
cattr_accessor :delimiter
|
12
|
+
self.delimiter = ','
|
13
|
+
|
14
|
+
attr_accessor :owner
|
15
|
+
|
16
|
+
def initialize(*args)
|
17
|
+
add(*args)
|
18
|
+
end
|
19
|
+
|
20
|
+
##
|
21
|
+
# Returns a new TagList using the given tag string.
|
22
|
+
#
|
23
|
+
# Example:
|
24
|
+
# tag_list = TagList.from("One , Two, Three")
|
25
|
+
# tag_list # ["One", "Two", "Three"]
|
26
|
+
def self.from(string)
|
27
|
+
if string.is_a?(Hash)
|
28
|
+
new(string)
|
29
|
+
else
|
30
|
+
glue = delimiter.ends_with?(" ") ? delimiter : "#{delimiter} "
|
31
|
+
string = string.join(glue) if string.respond_to?(:join)
|
32
|
+
|
33
|
+
new.tap do |tag_list|
|
34
|
+
string = string.to_s.dup
|
35
|
+
|
36
|
+
# Parse the quoted tags
|
37
|
+
string.gsub!(/(\A|#{delimiter})\s*"(.*?)"\s*(#{delimiter}\s*|\z)/) { tag_list << $2; $3 }
|
38
|
+
string.gsub!(/(\A|#{delimiter})\s*'(.*?)'\s*(#{delimiter}\s*|\z)/) { tag_list << $2; $3 }
|
39
|
+
|
40
|
+
tag_list.add(string.split(delimiter))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def concat(values)
|
46
|
+
super(values.map! { |v| convert_tag_value(v) })
|
47
|
+
end
|
48
|
+
|
49
|
+
def push(value)
|
50
|
+
super(convert_tag_value(value))
|
51
|
+
end
|
52
|
+
alias << push
|
53
|
+
|
54
|
+
##
|
55
|
+
# Add tags to the tag_list. Duplicate or blank tags will be ignored.
|
56
|
+
# Use the <tt>:parse</tt> option to add an unparsed tag string.
|
57
|
+
#
|
58
|
+
# Example:
|
59
|
+
# tag_list.add("Fun", "Happy")
|
60
|
+
# tag_list.add("Fun, Happy", :parse => true)
|
61
|
+
# tag_list.add("Fun" => "0.546", "Happy" => 0.465) # add relevance
|
62
|
+
def add(*values)
|
63
|
+
extract_and_apply_options!(values)
|
64
|
+
concat(values)
|
65
|
+
clean!
|
66
|
+
self
|
67
|
+
end
|
68
|
+
|
69
|
+
##
|
70
|
+
# Remove specific tags from the tag_list.
|
71
|
+
# Use the <tt>:parse</tt> option to add an unparsed tag string.
|
72
|
+
#
|
73
|
+
# Example:
|
74
|
+
# tag_list.remove("Sad", "Lonely")
|
75
|
+
# tag_list.remove("Sad, Lonely", :parse => true)
|
76
|
+
def remove(*values)
|
77
|
+
extract_and_apply_options!(values)
|
78
|
+
delete_if { |value| values.include?(value) }
|
79
|
+
self
|
80
|
+
end
|
81
|
+
|
82
|
+
##
|
83
|
+
# Transform the tag_list into a tag string suitable for edting in a form.
|
84
|
+
# The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
|
85
|
+
#
|
86
|
+
# Example:
|
87
|
+
# tag_list = TagList.new("Round", "Square,Cube")
|
88
|
+
# tag_list.to_s # 'Round, "Square,Cube"'
|
89
|
+
def to_s
|
90
|
+
tags = frozen? ? self.dup : self
|
91
|
+
tags.send(:clean!)
|
92
|
+
|
93
|
+
tags.map do |value|
|
94
|
+
value.include?(delimiter) ? "\"#{value}\"" : value
|
95
|
+
end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
|
96
|
+
end
|
97
|
+
|
98
|
+
# Builds an option statement for an ActiveRecord table.
|
99
|
+
def to_sql_conditions(options={})
|
100
|
+
options.reverse_merge!(:class => Tagtical::Tag, :column => "value", :operator => "=")
|
101
|
+
"(" + map { |t| options[:class].send(:sanitize_sql, ["#{options[:class].table_name}.#{options[:column]} #{options[:operator]} ?", t]) }.join(" OR ") + ")"
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
# Remove whitespace, duplicates, and blanks.
|
107
|
+
def clean!
|
108
|
+
reject!(&:blank?)
|
109
|
+
each(&:strip!)
|
110
|
+
uniq!(&:downcase)
|
111
|
+
end
|
112
|
+
|
113
|
+
def extract_and_apply_options!(args)
|
114
|
+
if args.size==1 && args[0].is_a?(Hash)
|
115
|
+
args.replace(args[0].map { |value, relevance| TagValue.new(value, relevance) })
|
116
|
+
else
|
117
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
118
|
+
options.assert_valid_keys :parse
|
119
|
+
|
120
|
+
if options[:parse]
|
121
|
+
args.map! { |a| self.class.from(a) }
|
122
|
+
end
|
123
|
+
|
124
|
+
args.flatten!
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def convert_tag_value(value)
|
129
|
+
value.is_a?(TagValue) ? value : TagValue.new(value)
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Tagtical::Taggable
|
2
|
+
module Cache
|
3
|
+
def self.included(base)
|
4
|
+
# Skip adding caching capabilities if table not exists or no cache columns exist
|
5
|
+
return unless base.table_exists? && base.tag_types.any? { |context| base.column_names.include?("cached_#{context.to_s.singularize}_list") }
|
6
|
+
|
7
|
+
base.send :include, Tagtical::Taggable::Cache::InstanceMethods
|
8
|
+
base.extend Tagtical::Taggable::Cache::ClassMethods
|
9
|
+
|
10
|
+
base.class_eval do
|
11
|
+
before_save :save_cached_tag_list
|
12
|
+
end
|
13
|
+
|
14
|
+
base.initialize_tagtical_cache
|
15
|
+
end
|
16
|
+
|
17
|
+
module ClassMethods
|
18
|
+
def initialize_tagtical_cache
|
19
|
+
tag_types.map(&:to_s).each do |tag_type|
|
20
|
+
class_eval %(
|
21
|
+
def self.caching_#{tag_type.singularize}_list?
|
22
|
+
caching_tag_list_on?("#{tag_type}")
|
23
|
+
end
|
24
|
+
)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def acts_as_taggable(*args)
|
29
|
+
super(*args)
|
30
|
+
initialize_tagtical_cache
|
31
|
+
end
|
32
|
+
|
33
|
+
def caching_tag_list_on?(context)
|
34
|
+
column_names.include?("cached_#{context.to_s.singularize}_list")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
module InstanceMethods
|
39
|
+
def save_cached_tag_list
|
40
|
+
tag_types.each do |tag_type|
|
41
|
+
if self.class.send("caching_#{tag_type.singularize}_list?")
|
42
|
+
if tag_list_cache_set_on?(tag_type)
|
43
|
+
list = tag_list_cache_on(tag_type.singularize).to_a.flatten.compact.join(', ')
|
44
|
+
self["cached_#{tag_type.singularize}_list"] = list
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
true
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module Tagtical::Taggable
|
2
|
+
module Collection
|
3
|
+
def self.included(base)
|
4
|
+
base.send :include, Tagtical::Taggable::Collection::InstanceMethods
|
5
|
+
base.extend Tagtical::Taggable::Collection::ClassMethods
|
6
|
+
base.initialize_tagtical_collection
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def initialize_tagtical_collection
|
11
|
+
tag_types.each do |tag_type|
|
12
|
+
class_eval %(
|
13
|
+
def self.#{tag_type.singularize}_counts(options={})
|
14
|
+
tag_counts_on('#{tag_type}', options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def #{tag_type.singularize}_counts(options = {})
|
18
|
+
tag_counts_on('#{tag_type}', options)
|
19
|
+
end
|
20
|
+
|
21
|
+
def top_#{tag_type}(limit = 10)
|
22
|
+
tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.top_#{tag_type}(limit = 10)
|
26
|
+
tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
|
27
|
+
end
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def acts_as_taggable(*args)
|
33
|
+
super(*args)
|
34
|
+
initialize_tagtical_collection
|
35
|
+
end
|
36
|
+
|
37
|
+
def tag_counts_on(context, options = {})
|
38
|
+
all_tag_counts(options.merge({:on => context.to_s}))
|
39
|
+
end
|
40
|
+
|
41
|
+
##
|
42
|
+
# Calculate the tag counts for all tags.
|
43
|
+
#
|
44
|
+
# @param [Hash] options Options:
|
45
|
+
# * :start_at - Restrict the tags to those created after a certain time
|
46
|
+
# * :end_at - Restrict the tags to those created before a certain time
|
47
|
+
# * :conditions - A piece of SQL conditions to add to the query
|
48
|
+
# * :limit - The maximum number of tags to return
|
49
|
+
# * :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
|
50
|
+
# * :at_least - Exclude tags with a frequency less than the given value
|
51
|
+
# * :at_most - Exclude tags with a frequency greater than the given value
|
52
|
+
# * :on - Scope the find to only include a certain tag type
|
53
|
+
def all_tag_counts(options = {})
|
54
|
+
options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on, :id
|
55
|
+
|
56
|
+
scope = if ActiveRecord::VERSION::MAJOR >= 3
|
57
|
+
{}
|
58
|
+
else
|
59
|
+
scope(:find) || {}
|
60
|
+
end
|
61
|
+
|
62
|
+
## Generate conditions:
|
63
|
+
options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
|
64
|
+
|
65
|
+
start_at_conditions = sanitize_sql(["#{Tagtical::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
|
66
|
+
end_at_conditions = sanitize_sql(["#{Tagtical::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
|
67
|
+
|
68
|
+
taggable_conditions = sanitize_sql(["#{Tagtical::Tagging.table_name}.taggable_type = ?", base_class.name])
|
69
|
+
taggable_conditions << sanitize_sql([" AND #{Tagtical::Tagging.table_name}.taggable_id = ?", options.delete(:id)]) if options[:id]
|
70
|
+
|
71
|
+
sti_conditions = Tagtical::Tag::Type[options[:on]].finder_type_condition if options[:on]
|
72
|
+
|
73
|
+
tagging_conditions = [
|
74
|
+
taggable_conditions,
|
75
|
+
scope[:conditions],
|
76
|
+
start_at_conditions,
|
77
|
+
end_at_conditions
|
78
|
+
].compact.reverse
|
79
|
+
|
80
|
+
tag_conditions = [
|
81
|
+
options[:conditions],
|
82
|
+
sti_conditions
|
83
|
+
].compact.reverse
|
84
|
+
|
85
|
+
## Generate joins:
|
86
|
+
taggable_join = "INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagtical::Tagging.table_name}.taggable_id"
|
87
|
+
taggable_join << " AND #{table_name}.#{inheritance_column} = '#{name}'" unless descends_from_active_record? # Current model is STI descendant, so add type checking to the join condition
|
88
|
+
|
89
|
+
tagging_joins = [
|
90
|
+
taggable_join,
|
91
|
+
scope[:joins]
|
92
|
+
].compact
|
93
|
+
|
94
|
+
tag_joins = [
|
95
|
+
].compact
|
96
|
+
|
97
|
+
[tagging_joins, tag_joins].each(&:reverse!) if ActiveRecord::VERSION::MAJOR < 3
|
98
|
+
|
99
|
+
## Generate scope:
|
100
|
+
tagging_scope = Tagtical::Tagging.select("#{Tagtical::Tagging.table_name}.tag_id, COUNT(#{Tagtical::Tagging.table_name}.tag_id) AS tags_count")
|
101
|
+
tag_scope = Tagtical::Tag.select("#{Tagtical::Tag.table_name}.*, #{Tagtical::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
|
102
|
+
|
103
|
+
# Joins and conditions
|
104
|
+
tagging_joins.each { |join| tagging_scope = tagging_scope.joins(join) }
|
105
|
+
tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) }
|
106
|
+
|
107
|
+
tag_joins.each { |join| tag_scope = tag_scope.joins(join) }
|
108
|
+
tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) }
|
109
|
+
|
110
|
+
# GROUP BY and HAVING clauses:
|
111
|
+
at_least = sanitize_sql(['tags_count >= ?', options.delete(:at_least)]) if options[:at_least]
|
112
|
+
at_most = sanitize_sql(['tags_count <= ?', options.delete(:at_most)]) if options[:at_most]
|
113
|
+
having = ["COUNT(#{Tagtical::Tagging.table_name}.tag_id) > 0", at_least, at_most].compact.join(' AND ')
|
114
|
+
|
115
|
+
group_columns = "#{Tagtical::Tagging.table_name}.tag_id"
|
116
|
+
|
117
|
+
if ActiveRecord::VERSION::MAJOR >= 3
|
118
|
+
# Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
|
119
|
+
scoped_select = "#{table_name}.#{primary_key}"
|
120
|
+
tagging_scope = tagging_scope.where("#{Tagtical::Tagging.table_name}.taggable_id IN(#{select(scoped_select).to_sql})").
|
121
|
+
group(group_columns).
|
122
|
+
having(having)
|
123
|
+
else
|
124
|
+
# Having is not available in 2.3.x:
|
125
|
+
group_by = "#{group_columns} HAVING COUNT(*) > 0"
|
126
|
+
group_by << " AND #{having}" unless having.blank?
|
127
|
+
tagging_scope = tagging_scope.group(group_by)
|
128
|
+
end
|
129
|
+
|
130
|
+
tag_scope = tag_scope.joins("JOIN (#{tagging_scope.to_sql}) AS taggings ON taggings.tag_id = tags.id")
|
131
|
+
tag_scope
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
module InstanceMethods
|
136
|
+
def tag_counts_on(context, options={})
|
137
|
+
self.class.tag_counts_on(context, options.merge(:id => id))
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|