tagtical 1.0.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/CHANGELOG +25 -0
  2. data/Gemfile +20 -0
  3. data/Gemfile.lock +25 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.rdoc +306 -0
  6. data/Rakefile +59 -0
  7. data/VERSION +1 -0
  8. data/generators/tagtical_migration/tagtical_migration_generator.rb +7 -0
  9. data/generators/tagtical_migration/templates/migration.rb +34 -0
  10. data/lib/generators/tagtical/migration/migration_generator.rb +32 -0
  11. data/lib/generators/tagtical/migration/templates/active_record/migration.rb +35 -0
  12. data/lib/tagtical/acts_as_tagger.rb +69 -0
  13. data/lib/tagtical/compatibility/Gemfile +8 -0
  14. data/lib/tagtical/compatibility/active_record_backports.rb +21 -0
  15. data/lib/tagtical/tag.rb +314 -0
  16. data/lib/tagtical/tag_list.rb +133 -0
  17. data/lib/tagtical/taggable/cache.rb +53 -0
  18. data/lib/tagtical/taggable/collection.rb +141 -0
  19. data/lib/tagtical/taggable/core.rb +317 -0
  20. data/lib/tagtical/taggable/ownership.rb +110 -0
  21. data/lib/tagtical/taggable/related.rb +60 -0
  22. data/lib/tagtical/taggable.rb +51 -0
  23. data/lib/tagtical/tagging.rb +42 -0
  24. data/lib/tagtical/tags_helper.rb +17 -0
  25. data/lib/tagtical.rb +47 -0
  26. data/rails/init.rb +1 -0
  27. data/spec/bm.rb +53 -0
  28. data/spec/database.yml +17 -0
  29. data/spec/database.yml.sample +17 -0
  30. data/spec/models.rb +60 -0
  31. data/spec/schema.rb +46 -0
  32. data/spec/spec_helper.rb +159 -0
  33. data/spec/tagtical/acts_as_tagger_spec.rb +94 -0
  34. data/spec/tagtical/tag_list_spec.rb +102 -0
  35. data/spec/tagtical/tag_spec.rb +301 -0
  36. data/spec/tagtical/taggable_spec.rb +460 -0
  37. data/spec/tagtical/tagger_spec.rb +76 -0
  38. data/spec/tagtical/tagging_spec.rb +52 -0
  39. data/spec/tagtical/tags_helper_spec.rb +28 -0
  40. data/spec/tagtical/tagtical_spec.rb +340 -0
  41. metadata +132 -0
@@ -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