tagtical 1.0.6
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/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
|