public
Description: A tagging plugin for Rails applications that allows for custom tagging along dynamic contexts.
Homepage: http://mbleigh.lighthouseapp.com/projects/10116-acts-as-taggable-on
Clone URL: git://github.com/mbleigh/acts-as-taggable-on.git
Click here to lend your support to: acts-as-taggable-on and make a donation at www.pledgie.com !
acts-as-taggable-on / lib / active_record / acts / taggable_on.rb
100644 293 lines (239 sloc) 12.864 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
module ActiveRecord
  module Acts
    module TaggableOn
      def self.included(base)
        base.extend(ClassMethods)
      end
      
      module ClassMethods
        def acts_as_taggable
          acts_as_taggable_on :tags
        end
        
        def acts_as_taggable_on(*args)
          puts "Registering #{args.inspect} with #{self.inspect}"
          for tag_type in args
            tag_type = tag_type.to_s
            self.class_eval do
              has_many "#{tag_type.singularize}_taggings".to_sym, :as => :taggable, :dependent => :destroy,
                :include => :tag, :conditions => ["context = ?",tag_type], :class_name => "Tagging"
              has_many "#{tag_type}".to_sym, :through => "#{tag_type.singularize}_taggings".to_sym, :source => :tag
            end
            
            self.class_eval <<-RUBY
def self.caching_#{tag_type.singularize}_list?
caching_tag_list_on?("#{tag_type}")
end
def self.#{tag_type.singularize}_counts(options={})
tag_counts_on('#{tag_type}',options)
end
def #{tag_type.singularize}_list
tag_list_on('#{tag_type}')
end
def #{tag_type.singularize}_list=(new_tags)
set_tag_list_on('#{tag_type}',new_tags)
end
def #{tag_type.singularize}_counts(options = {})
tag_counts_on('#{tag_type}',options)
end
def #{tag_type}_from(owner)
tag_list_on('#{tag_type}', owner)
end
def find_related_#{tag_type}(options = {})
related_tags_on('#{tag_type}',options)
end
alias_method :find_related_on_#{tag_type}, :find_related_#{tag_type}
RUBY
          end
          
          if respond_to?(:tag_types)
            puts "Appending #{args.inspect} onto #{tag_types.inspect}"
            write_inheritable_attribute(:tag_types, tag_types + args)
          else
            self.class_eval do
              write_inheritable_attribute(:tag_types, args)
              class_inheritable_reader :tag_types
            
              has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag
              has_many :base_tags, :class_name => "Tag", :through => :taggings, :source => :tag
            
              attr_writer :custom_contexts
            
              before_save :save_cached_tag_list
              after_save :save_tags
            end
            
            include ActiveRecord::Acts::TaggableOn::InstanceMethods
            extend ActiveRecord::Acts::TaggableOn::SingletonMethods
            alias_method_chain :reload, :tag_list
          end
        end
        
        def is_taggable?
          false
        end
      end
      
      module SingletonMethods
        # Pass either a tag string, or an array of strings or tags
        #
        # Options:
        # :exclude - Find models that are not tagged with the given tags
        # :match_all - Find models that match all of the given tags, not just one
        # :conditions - A piece of SQL conditions to add to the query
        # :on - scopes the find to a context
        def find_tagged_with(*args)
          options = find_options_for_find_tagged_with(*args)
          options.blank? ? [] : find(:all,options)
        end
        
        def caching_tag_list_on?(context)
          column_names.include?("cached_#{context.to_s.singularize}_list")
        end
        
        def tag_counts_on(context, options = {})
          Tag.find(:all, find_options_for_tag_counts(options.merge({:on => context.to_s})))
        end
        
        def find_options_for_find_tagged_with(tags, options = {})
          tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
 
          return {} if tags.empty?
 
          conditions = []
          conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
          
          unless (on = options.delete(:on)).nil?
            conditions << sanitize_sql(["context = ?",on.to_s])
          end
 
          taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
 
          if options.delete(:exclude)
            tags_conditions = tags.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
            conditions << sanitize_sql(["#{table_name}.id NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} LEFT OUTER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id WHERE (#{tags_conditions}) AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})", tags])
          else
            conditions << tags.map { |t| sanitize_sql(["#{tags_alias}.name LIKE ?", t]) }.join(" OR ")
 
            if options.delete(:match_all)
              group = "#{taggings_alias}.taggable_id HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
            end
          end
          
          { :select => "DISTINCT #{table_name}.*",
            :joins => "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)} " +
                      "LEFT OUTER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id",
            :conditions => conditions.join(" AND "),
            :group => group
          }.update(options)
        end
        
        # Calculate the tag counts for all tags.
        #
        # Options:
        # :start_at - Restrict the tags to those created after a certain time
        # :end_at - Restrict the tags to those created before a certain time
        # :conditions - A piece of SQL conditions to add to the query
        # :limit - The maximum number of tags to return
        # :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
        # :at_least - Exclude tags with a frequency less than the given value
        # :at_most - Exclude tags with a frequency greater than the given value
        # :on - Scope the find to only include a certain context
        def find_options_for_tag_counts(options = {})
          options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on
          
          scope = scope(:find)
          start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
          end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
 
          type_and_context = "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}"
          
          conditions = [
            type_and_context,
            options[:conditions],
            start_at,
            end_at
          ]
 
          conditions = conditions.compact.join(' AND ')
          conditions = merge_conditions(conditions, scope[:conditions]) if scope
 
          joins = ["LEFT OUTER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id"]
          joins << sanitize_sql(["AND #{Tagging.table_name}.context = ?",options.delete(:on).to_s]) unless options[:on].nil?
          joins << "LEFT OUTER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"
          joins << scope[:joins] if scope && scope[:joins]
 
          at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
          at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
          having = [at_least, at_most].compact.join(' AND ')
          group_by = "#{Tag.table_name}.id, #{Tag.table_name}.name HAVING COUNT(*) > 0"
          group_by << " AND #{having}" unless having.blank?
 
          { :select => "#{Tag.table_name}.id, #{Tag.table_name}.name, COUNT(*) AS count",
            :joins => joins.join(" "),
            :conditions => conditions,
            :group => group_by
          }.update(options)
        end
        
        def is_taggable?
          true
        end
      end
    
      module InstanceMethods
        
        def tag_types
          self.class.tag_types
        end
        
        def custom_contexts
          @custom_contexts ||= []
        end
        
        def is_taggable?
          self.class.is_taggable?
        end
        
        def add_custom_context(value)
          custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s)
        end
        
        def tag_list_on(context, owner=nil)
          var_name = context.to_s.singularize + "_list"
          return instance_variable_get("@#{var_name}") unless instance_variable_get("@#{var_name}").nil?
        
          if !owner && self.class.caching_tag_list_on?(context) and !(cached_value = cached_tag_list_on(context, owner)).nil?
            instance_variable_set("@#{var_name}", TagList.from(self["cached_#{var_name}"]))
          else
            instance_variable_set("@#{var_name}", TagList.new(*tags_on(context, owner).map(&:name)))
          end
        end
        
        def tags_on(context, owner=nil)
          if owner
            opts = {:conditions => ["context = ? AND tagger_id = ? AND tagger_type = ?",
                                    context.to_s, owner.id, owner.class.to_s]}
          else
            opts = {:conditions => ["context = ?", context.to_s]}
          end
          base_tags.find(:all, opts)
        end
        
        def cached_tag_list_on(context)
          self["cached_#{context.to_s.singularize}_list"]
        end
        
        def set_tag_list_on(context,new_list, tagger=nil)
          instance_variable_set("@#{context.to_s.singularize}_list", TagList.from_owner(tagger, new_list))
          add_custom_context(context)
        end
        
        def tag_counts_on(context,options={})
          self.class.tag_counts_on(context,{:conditions => ["#{Tag.table_name}.name IN (?)", tag_list_on(context)]}.reverse_merge!(options))
        end
        
        def related_tags_on(context, options={})
          tags_to_find = self.tags_on(context).collect {|t| t.name}
          search_conditions = {
            :select => "#{self.class.table_name}.*, COUNT(#{Tag.table_name}.id) AS count",
            :from => "#{self.class.table_name}, #{Tag.table_name}, #{Tagging.table_name}",
            :conditions => ["#{self.class.table_name}.id != #{self.id} AND #{self.class.table_name}.id = #{Tagging.table_name}.taggable_id AND #{Tagging.table_name}.taggable_type = '#{self.class.to_s}' AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND #{Tag.table_name}.name IN (?)",tags_to_find],
            :group => "#{self.class.table_name}.id",
            :order => "count DESC"
          }.update(options)
          
          self.class.find(:all, search_conditions)
        end
        
        def save_cached_tag_list
          self.class.tag_types.map(&:to_s).each do |tag_type|
            if self.class.send("caching_#{tag_type.singularize}_list?")
              self["cached_#{tag_type.singularize}_list"] = send("#{tag_type.singularize}_list").to_s
            end
          end
        end
        
        def save_tags
          (custom_contexts + self.class.tag_types.map(&:to_s)).each do |tag_type|
            next unless instance_variable_get("@#{tag_type.singularize}_list")
            owner = instance_variable_get("@#{tag_type.singularize}_list").owner
            new_tag_names = instance_variable_get("@#{tag_type.singularize}_list") - tags_on(tag_type).map(&:name)
            old_tags = tags_on(tag_type).reject { |tag| instance_variable_get("@#{tag_type.singularize}_list").include?(tag.name) }
          
            self.class.transaction do
              base_tags.delete(*old_tags) if old_tags.any?
              new_tag_names.each do |new_tag_name|
                new_tag = Tag.find_or_create_with_like_by_name(new_tag_name)
                Tagging.create(:tag_id => new_tag.id, :context => tag_type,
                               :taggable => self, :tagger => owner)
              end
            end
          end
          
          true
        end
        
        def reload_with_tag_list(*args)
          self.class.tag_types.each do |tag_type|
            self.instance_variable_set("@#{tag_type.to_s.singularize}_list", nil)
          end
          
          reload_without_tag_list(*args)
        end
      end
    end
  end
end