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.
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