bborn-acts_as_taggable_on_steroids 2.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,246 @@
1
+ require 'tag_list'
2
+ require 'tag'
3
+ require 'tagging'
4
+ require 'tags_helper'
5
+
6
+ module ActiveRecord #:nodoc:
7
+ module Acts #:nodoc:
8
+ module Taggable #:nodoc:
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ def acts_as_taggable
15
+ has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag
16
+ has_many :tags, :through => :taggings
17
+
18
+ before_save :save_cached_tag_list
19
+ after_save :save_tags
20
+
21
+ include ActiveRecord::Acts::Taggable::InstanceMethods
22
+ extend ActiveRecord::Acts::Taggable::SingletonMethods
23
+
24
+ alias_method_chain :reload, :tag_list
25
+ end
26
+
27
+ def cached_tag_list_column_name
28
+ "cached_tag_list"
29
+ end
30
+
31
+ def set_cached_tag_list_column_name(value = nil, &block)
32
+ define_attr_method :cached_tag_list_column_name, value, &block
33
+ end
34
+ end
35
+
36
+ module SingletonMethods
37
+ # Pass either a tag, string, or an array of strings or tags.
38
+ #
39
+ # Options:
40
+ # - +:match_any+ - match any of the given tags (default).
41
+ # - +:match_all+ - match all of the given tags.
42
+ #
43
+ def tagged_with(tags, options = {})
44
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
45
+ return [] if tags.empty?
46
+
47
+ records = select("DISTINCT #{quoted_table_name}.*")
48
+
49
+ if options[:match_all]
50
+ records.search_all_tags(tags)
51
+ else
52
+ records.search_any_tags(tags)
53
+ end
54
+ end
55
+
56
+ # Matches records that have none of the given tags.
57
+ def not_tagged_with(tags)
58
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
59
+
60
+ sub = Tagging.select("#{Tagging.table_name}.taggable_id").joins(:tag).
61
+ where(:taggable_type => base_class.name, "#{Tag.table_name}.name" => tags)
62
+
63
+ where("#{quoted_table_name}.#{primary_key} NOT IN (" + sub.to_sql + ")")
64
+ end
65
+
66
+ # Returns an array of related tags. Related tags are all the other tags
67
+ # that are found on the models tagged with the provided tags.
68
+ def related_tags(tags)
69
+ search_related_tags(tags)
70
+ end
71
+
72
+ # Counts the number of occurences of all tags.
73
+ # See <tt>Tag.counts</tt> for options.
74
+ def tag_counts(options = {})
75
+ tags = Tag.joins(:taggings).
76
+ where("#{Tagging.table_name}.taggable_type" => base_class.name)
77
+
78
+ if options[:tags]
79
+ tags = tags.where("#{Tag.table_name}.name" => options.delete(:tags))
80
+ end
81
+
82
+ unless descends_from_active_record?
83
+ tags = tags.joins("INNER JOIN #{quoted_table_name} ON " +
84
+ "#{quoted_table_name}.#{primary_key} = #{Tagging.quoted_table_name}.taggable_id")
85
+ tags = tags.where(type_condition)
86
+ end
87
+
88
+ if scoped != unscoped
89
+ sub = scoped.except(:select).select("#{quoted_table_name}.#{primary_key}")
90
+ tags = tags.where("#{Tagging.quoted_table_name}.taggable_id IN (#{sub.to_sql})")
91
+ end
92
+
93
+ tags.counts(options)
94
+ end
95
+
96
+ # Returns an array of related tags.
97
+ # Related tags are all the other tags that are found on the models
98
+ # tagged with the provided tags.
99
+ #
100
+ # Pass either a tag, string, or an array of strings or tags.
101
+ #
102
+ # Options:
103
+ # - +:order+ - SQL Order how to order the tags. Defaults to "count_all DESC, tags.name".
104
+ # - +:include+
105
+ #
106
+ # DEPRECATED: use #related_tags instead.
107
+ def find_related_tags(tags, options = {})
108
+ rs = related_tags(tags).order(options[:order] || "count DESC, #{Tag.quoted_table_name}.name")
109
+ rs = rs.includes(options[:include]) if options[:include]
110
+ rs
111
+ end
112
+
113
+ # Pass either a tag, string, or an array of strings or tags.
114
+ #
115
+ # Options:
116
+ # - +:exclude+ - Find models that are not tagged with the given tags
117
+ # - +:match_all+ - Find models that match all of the given tags, not just one
118
+ # - +:conditions+ - A piece of SQL conditions to add to the query
119
+ # - +:include+
120
+ #
121
+ # DEPRECATED: use #tagged_with and #not_tagged_with instead.
122
+ def find_tagged_with(*args)
123
+ options = args.extract_options!
124
+ tags = args.first
125
+
126
+ records = self
127
+ records = records.where(options[:conditions]) if options[:conditions]
128
+ records = records.includes(options[:include]) if options[:include]
129
+ records = records.order(options[:order]) if options[:order]
130
+
131
+ if options[:exclude]
132
+ records.not_tagged_with(tags)
133
+ else
134
+ records.tagged_with(tags, options)
135
+ end
136
+ end
137
+
138
+ def caching_tag_list?
139
+ column_names.include?(cached_tag_list_column_name)
140
+ end
141
+
142
+ protected
143
+ def joins_tags(options = {}) # :nodoc:
144
+ options[:suffix] = "_#{options[:suffix]}" if options[:suffix]
145
+
146
+ taggings_alias = connection.quote_table_name(Tagging.table_name + options[:suffix].to_s)
147
+ tags_alias = connection.quote_table_name(Tag.table_name + options[:suffix].to_s)
148
+
149
+ taggings = "INNER JOIN #{Tagging.quoted_table_name} AS #{taggings_alias} " +
150
+ "ON #{taggings_alias}.taggable_id = #{quoted_table_name}.#{primary_key} " +
151
+ "AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
152
+
153
+ tags = "INNER JOIN #{Tag.quoted_table_name} AS #{tags_alias} " +
154
+ "ON #{tags_alias}.id = #{taggings_alias}.tag_id "
155
+ tags += "AND #{tags_alias}.name LIKE #{quote_value(options[:tag_name])}" if options[:tag_name]
156
+
157
+ joins([taggings, tags])
158
+ end
159
+
160
+ def search_all_tags(tags)
161
+ records = self
162
+
163
+ tags.dup.each_with_index do |tag_name, index|
164
+ records = records.joins_tags(:suffix => index, :tag_name => tag_name)
165
+ end
166
+
167
+ records
168
+ end
169
+
170
+ def search_any_tags(tags)
171
+ joins(:tags).where(Tag.arel_table[:name].matches_any(tags.dup))
172
+ end
173
+
174
+ def search_related_tags(tags)
175
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
176
+ sub = select("#{quoted_table_name}.#{primary_key}").search_any_tags(tags)
177
+ _tags = tags.map { |tag| tag.downcase }
178
+
179
+ Tag.select("#{Tag.quoted_table_name}.*, COUNT(#{Tag.quoted_table_name}.id) AS count").
180
+ joins(:taggings).
181
+ where("#{Tagging.table_name}.taggable_type" => base_class.name).
182
+ where("#{Tagging.quoted_table_name}.taggable_id IN (" + sub.to_sql + ")").
183
+ group("#{Tag.quoted_table_name}.name").
184
+ having(Tag.arel_table[:name].does_not_match_all(_tags))
185
+ end
186
+ end
187
+
188
+ module InstanceMethods
189
+ def tag_list
190
+ return @tag_list if @tag_list
191
+
192
+ if self.class.caching_tag_list? and !(cached_value = send(self.class.cached_tag_list_column_name)).nil?
193
+ @tag_list = TagList.from(cached_value)
194
+ else
195
+ @tag_list = TagList.new(*tags.map(&:name))
196
+ end
197
+ end
198
+
199
+ def tag_list=(value)
200
+ @tag_list = TagList.from(value)
201
+ end
202
+
203
+ def save_cached_tag_list
204
+ if self.class.caching_tag_list?
205
+ self[self.class.cached_tag_list_column_name] = tag_list.to_s
206
+ end
207
+ end
208
+
209
+ def save_tags
210
+ return unless @tag_list
211
+
212
+ new_tag_names = @tag_list - tags.map(&:name)
213
+ old_tags = tags.reject { |tag| @tag_list.include?(tag.name) }
214
+
215
+ self.class.transaction do
216
+ if old_tags.any?
217
+ taggings.where(:tag_id => old_tags.map(&:id)).each(&:destroy)
218
+ taggings.reset
219
+ end
220
+
221
+ new_tag_names.each do |new_tag_name|
222
+ tags << Tag.find_or_create_with_like_by_name(new_tag_name)
223
+ end
224
+ end
225
+
226
+ true
227
+ end
228
+
229
+ # Calculate the tag counts for the tags used by this model.
230
+ # See <tt>Tag.counts</tt> for available options.
231
+ def tag_counts(options = {})
232
+ return [] if tag_list.blank?
233
+ self.class.tag_counts(options.merge(:tags => tag_list))
234
+ end
235
+
236
+ def reload_with_tag_list(*args) #:nodoc:
237
+ @tag_list = nil
238
+ reload_without_tag_list(*args)
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+
245
+ ActiveRecord::Base.send(:include, ActiveRecord::Acts::Taggable)
246
+
@@ -0,0 +1,24 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ class ActsAsTaggableMigrationGenerator < Rails::Generators::Base
5
+ include Rails::Generators::Migration
6
+
7
+ def self.source_root
8
+ @source_root ||= File.expand_path(File.join(File.dirname(__FILE__), 'templates'))
9
+ end
10
+
11
+ def self.next_migration_number(dirname)
12
+ Time.now.strftime("%Y%m%d%H%M%S")
13
+ end
14
+
15
+ def create_migration
16
+ migration_template "migration.rb", File.join("db/migrate", "#{file_name}.rb")
17
+ end
18
+
19
+ protected
20
+
21
+ def file_name
22
+ "acts_as_taggable_migration"
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ class ActsAsTaggableMigration < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :tags do |t|
4
+ t.string :name
5
+ end
6
+
7
+ create_table :taggings do |t|
8
+ t.references :tag
9
+ t.references :taggable, :polymorphic => true
10
+ t.datetime :created_at
11
+ end
12
+
13
+ add_index :tags, :name
14
+ add_index :taggings, :tag_id
15
+ add_index :taggings, [:taggable_id, :taggable_type]
16
+ end
17
+
18
+ def self.down
19
+ drop_table :taggings
20
+ drop_table :tags
21
+ end
22
+ end
data/lib/tag.rb ADDED
@@ -0,0 +1,59 @@
1
+ require 'active_support/core_ext/module/deprecation'
2
+
3
+ class Tag < ActiveRecord::Base
4
+ cattr_accessor :destroy_unused
5
+ self.destroy_unused = false
6
+
7
+ has_many :taggings, :dependent => :delete_all
8
+
9
+ validates_presence_of :name
10
+ validates_uniqueness_of :name
11
+
12
+ def ==(object)
13
+ super || (object.is_a?(Tag) && name == object.name)
14
+ end
15
+
16
+ def to_s
17
+ name
18
+ end
19
+
20
+ def count
21
+ read_attribute(:count).to_i
22
+ end
23
+
24
+ class << self
25
+ def find_or_create_with_like_by_name(name)
26
+ where(arel_table[:name].matches(name)).first || create(:name => name)
27
+ end
28
+
29
+ # Calculate the tag counts for all tags.
30
+ #
31
+ # - +:start_at+ - restrict the tags to those created after a certain time
32
+ # - +:end_at+ - restrict the tags to those created before a certain time
33
+ # - +:at_least+ - exclude tags with a frequency less than the given value
34
+ # - +:at_most+ - exclude tags with a frequency greater than the given value
35
+ #
36
+ # Deprecated:
37
+ #
38
+ # - +:conditions+
39
+ # - +:limit+
40
+ # - +:order+
41
+ #
42
+ def counts(options = {})
43
+ options.assert_valid_keys :start_at, :end_at, :at_least, :at_most, :conditions, :limit, :order
44
+
45
+ tags = joins(:taggings).group(:name)
46
+ tags = tags.having(['count >= ?', options[:at_least]]) if options[:at_least]
47
+ tags = tags.having(['count <= ?', options[:at_most]]) if options[:at_most]
48
+ tags = tags.where("#{Tagging.quoted_table_name}.created_at >= ?", options[:start_at]) if options[:start_at]
49
+ tags = tags.where("#{Tagging.quoted_table_name}.created_at <= ?", options[:end_at]) if options[:end_at]
50
+
51
+ # TODO: deprecation warning
52
+ tags = tags.where(options[:conditions]) if options[:conditions]
53
+ tags = tags.limit(options[:limit]) if options[:limit]
54
+ tags = tags.order(options[:order]) if options[:order]
55
+
56
+ tags.select("#{quoted_table_name}.*, COUNT(#{quoted_table_name}.id) AS count")
57
+ end
58
+ end
59
+ end
data/lib/tag_list.rb ADDED
@@ -0,0 +1,109 @@
1
+ class TagList < Array
2
+ cattr_accessor :delimiter
3
+ self.delimiter = ','
4
+
5
+ def initialize(*args)
6
+ add(*args)
7
+ end
8
+
9
+ # Add tags to the tag_list. Duplicate or blank tags will be ignored.
10
+ #
11
+ # tag_list.add("Fun", "Happy")
12
+ #
13
+ # Use the <tt>:parse</tt> option to add an unparsed tag string.
14
+ #
15
+ # tag_list.add("Fun, Happy", :parse => true)
16
+ def add(*names)
17
+ extract_and_apply_options!(names)
18
+ concat(names)
19
+ clean!
20
+ self
21
+ end
22
+
23
+ # Remove specific tags from the tag_list.
24
+ #
25
+ # tag_list.remove("Sad", "Lonely")
26
+ #
27
+ # Like #add, the <tt>:parse</tt> option can be used to remove multiple tags in a string.
28
+ #
29
+ # tag_list.remove("Sad, Lonely", :parse => true)
30
+ def remove(*names)
31
+ extract_and_apply_options!(names)
32
+ delete_if { |name| names.include?(name) }
33
+ self
34
+ end
35
+
36
+ # Toggle the presence of the given tags.
37
+ # If a tag is already in the list it is removed, otherwise it is added.
38
+ def toggle(*names)
39
+ extract_and_apply_options!(names)
40
+
41
+ names.each do |name|
42
+ include?(name) ? delete(name) : push(name)
43
+ end
44
+
45
+ clean!
46
+ self
47
+ end
48
+
49
+ # Transform the tag_list into a tag string suitable for edting in a form.
50
+ # The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
51
+ #
52
+ # tag_list = TagList.new("Round", "Square,Cube")
53
+ # tag_list.to_s # 'Round, "Square,Cube"'
54
+ def to_s
55
+ clean!
56
+
57
+ map do |name|
58
+ name.include?(delimiter) ? "\"#{name}\"" : name
59
+ end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
60
+ end
61
+
62
+ private
63
+ # Remove whitespace, duplicates, and blanks.
64
+ def clean!
65
+ reject!(&:blank?)
66
+ map!(&:strip)
67
+ uniq!
68
+ end
69
+
70
+ def extract_and_apply_options!(args)
71
+ options = args.last.is_a?(Hash) ? args.pop : {}
72
+ options.assert_valid_keys :parse
73
+
74
+ if options[:parse]
75
+ args.map! { |a| self.class.from(a) }
76
+ end
77
+
78
+ args.flatten!
79
+ end
80
+
81
+ class << self
82
+ # Returns a new TagList using the given tag string.
83
+ #
84
+ # tag_list = TagList.from("One , Two, Three")
85
+ # tag_list # ["One", "Two", "Three"]
86
+ def from(source)
87
+ tag_list = new
88
+
89
+ case source
90
+ when Array
91
+ tag_list.add(source)
92
+ else
93
+ string = source.to_s.dup
94
+
95
+ # Parse the quoted tags
96
+ [
97
+ /\s*#{delimiter}\s*(['"])(.*?)\1\s*/,
98
+ /^\s*(['"])(.*?)\1\s*#{delimiter}?/
99
+ ].each do |re|
100
+ string.gsub!(re) { tag_list << $2; "" }
101
+ end
102
+
103
+ tag_list.add(string.split(delimiter))
104
+ end
105
+
106
+ tag_list
107
+ end
108
+ end
109
+ end
data/lib/tagging.rb ADDED
@@ -0,0 +1,16 @@
1
+ class Tagging < ActiveRecord::Base #:nodoc:
2
+ belongs_to :tag
3
+ belongs_to :taggable, :polymorphic => true
4
+
5
+ after_destroy :destroy_tag_if_unused
6
+
7
+ private
8
+
9
+ def destroy_tag_if_unused
10
+ if Tag.destroy_unused
11
+ if tag.taggings.count.zero?
12
+ tag.destroy
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ module TagsHelper
2
+ # See the README for an example using tag_cloud.
3
+ def tag_cloud(tags, classes)
4
+ return if tags.all.empty?
5
+
6
+ max_count = tags.sort_by(&:count).last.count.to_f
7
+
8
+ tags.each do |tag|
9
+ index = ((tag.count / max_count) * (classes.size - 1)).round
10
+ yield tag, classes[index]
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,106 @@
1
+ require 'test/unit'
2
+
3
+ begin
4
+ require File.dirname(__FILE__) + '/../../../../config/environment'
5
+ rescue LoadError
6
+ require 'rubygems'
7
+ gem 'activesupport'
8
+ gem 'activerecord'
9
+ gem 'actionpack'
10
+ require 'active_support/dependencies'
11
+ require 'active_record'
12
+ require 'action_controller'
13
+ end
14
+
15
+ # Search for fixtures first
16
+ fixture_path = File.dirname(__FILE__) + '/fixtures/'
17
+ ActiveSupport::Dependencies.autoload_paths.insert(0, fixture_path)
18
+
19
+ require "active_record/test_case"
20
+ require "active_record/fixtures"
21
+
22
+ require File.dirname(__FILE__) + '/../lib/acts_as_taggable'
23
+ require_dependency File.dirname(__FILE__) + '/../lib/tag_list'
24
+ require_dependency File.dirname(__FILE__) + '/../lib/tags_helper'
25
+
26
+ ENV['DB'] ||= 'sqlite3'
27
+
28
+ ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + '/debug.log')
29
+ ActiveRecord::Base.configurations = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
30
+ ActiveRecord::Base.establish_connection(ENV['DB'])
31
+
32
+ load(File.dirname(__FILE__) + '/schema.rb')
33
+
34
+ class ActiveSupport::TestCase #:nodoc:
35
+ include ActiveRecord::TestFixtures
36
+
37
+ self.fixture_path = File.dirname(__FILE__) + "/fixtures/"
38
+
39
+ self.use_transactional_fixtures = true
40
+ self.use_instantiated_fixtures = false
41
+
42
+ fixtures :all
43
+
44
+ def assert_equivalent(expected, actual, message = nil)
45
+ if expected.first.is_a?(ActiveRecord::Base)
46
+ assert_equal expected.sort_by(&:id), actual.sort_by(&:id), message
47
+ else
48
+ assert_equal expected.sort, actual.sort, message
49
+ end
50
+ end
51
+
52
+ def assert_tag_counts(tags, expected_values)
53
+ # Map the tag fixture names to real tag names
54
+ expected_values = expected_values.inject({}) do |hash, (tag, count)|
55
+ hash[tags(tag).name] = count
56
+ hash
57
+ end
58
+
59
+ tags.each do |tag|
60
+ value = expected_values.delete(tag.name)
61
+
62
+ assert_not_nil value, "Expected count for #{tag.name} was not provided"
63
+ assert_equal value, tag.count, "Expected value of #{value} for #{tag.name}, but was #{tag.count}"
64
+ end
65
+
66
+ unless expected_values.empty?
67
+ assert false, "The following tag counts were not present: #{expected_values.inspect}"
68
+ end
69
+ end
70
+
71
+ def assert_queries(num = 1)
72
+ $query_count = 0
73
+ yield
74
+ ensure
75
+ assert_equal num, $query_count, "#{$query_count} instead of #{num} queries were executed."
76
+ end
77
+
78
+ def assert_no_queries(&block)
79
+ assert_queries(0, &block)
80
+ end
81
+
82
+ # From Rails trunk
83
+ def assert_difference(expressions, difference = 1, message = nil, &block)
84
+ expression_evaluations = [expressions].flatten.collect{|expression| lambda { eval(expression, block.binding) } }
85
+
86
+ original_values = expression_evaluations.inject([]) { |memo, expression| memo << expression.call }
87
+ yield
88
+ expression_evaluations.each_with_index do |expression, i|
89
+ assert_equal original_values[i] + difference, expression.call, message
90
+ end
91
+ end
92
+
93
+ def assert_no_difference(expressions, message = nil, &block)
94
+ assert_difference expressions, 0, message, &block
95
+ end
96
+ end
97
+
98
+ ActiveRecord::Base.connection.class.class_eval do
99
+ def execute_with_counting(sql, name = nil, &block)
100
+ $query_count ||= 0
101
+ $query_count += 1
102
+ execute_without_counting(sql, name, &block)
103
+ end
104
+
105
+ alias_method_chain :execute, :counting
106
+ end