ggoodale-acts_as_taggable_on_steroids 1.1

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 ADDED
@@ -0,0 +1,167 @@
1
+ [13 March 08]
2
+
3
+ * Added helper methods for will_paginate compatibility (Clinton R. Nixon)
4
+ * Fixed :conditions in tag_counts to accept array to sanitize (Clinton R. Nixon)
5
+
6
+ [07 March 08]
7
+
8
+ * Added support for regexp delimiter (Matt Aimonetti)
9
+
10
+ [30 Jan 08]
11
+
12
+ * Fix Tag.destroy_unused on Rails 2.0.
13
+
14
+ [23 October 2007]
15
+
16
+ * Make find_options_for_tag_counts and find_options_for_tagged_with dup their options.
17
+
18
+ * Apply conditions properly in find_options_for_tag_counts.
19
+
20
+ * Fix tag_cloud when no tags are present.
21
+
22
+ [22 October 2007]
23
+
24
+ * Fix find_tagged_with using :match_all and :include.
25
+
26
+ * Use inner joins instead of left outer joins.
27
+
28
+ [15 October 2007]
29
+
30
+ * Make find_tagged_with correctly apply :conditions
31
+
32
+ * Add Tag.destroy_unused option.
33
+
34
+ [11 October 2007]
35
+
36
+ * Make tag_counts work correctly with STI.
37
+
38
+ [3 October 2007]
39
+
40
+ * Improve documentation.
41
+
42
+ * Fix TagsHelper and test.
43
+
44
+ [2 October 2007]
45
+
46
+ * Remove TagList.parse, use TagList.from instead.
47
+
48
+ * Add :parse option to TagList#new, TagList#add, and TagList#remove.
49
+
50
+ tag_list = TagList.new("One, Two", :parse => true) # ["One", "Two"]
51
+
52
+ tag_list # ["One", "Two"]
53
+ tag_list.add("Three, Four", :parse => true) # ["One", "Two", "Three", "Four"]
54
+
55
+ * Remove TagList#names.
56
+
57
+ [29 September 2007]
58
+
59
+ * Add TagsHelper to assist with generating tag clouds and provide a simple example.
60
+
61
+ [27 September 2007]
62
+
63
+ * Add #tag_counts method to get tag counts for a specific object's tags.
64
+
65
+ * BACKWARDS INCOMPATIBILITY: Rename #find_options_for_tagged_with to #find_options_for_find_tagged_with
66
+
67
+ [17 September 2007]
68
+
69
+ * Fix clearing of cached tag list when all tags removed.
70
+
71
+ [12 September 2007]
72
+
73
+ * Make the TagList class inherit from Array.
74
+
75
+ * Deprecate obsolete TagList#names.
76
+
77
+ [6 September 2007]
78
+
79
+ * Add TagList#include? and TagList#empty?
80
+
81
+ [26 August 2006]
82
+
83
+ * Remove deprecated Tag.delimiter. Use TagList.delimiter instead.
84
+
85
+ [25 August 2007]
86
+
87
+ * Make tag_counts work with has_many :through
88
+
89
+ [23 August 2007]
90
+
91
+ * Make search comparisons case-insensitive across different databases. [Moisés Machado]
92
+
93
+ * Improve compatiblity with STI. [Moisés Machado]
94
+
95
+ [25 July 2007]
96
+
97
+ * Respect custom table names for the Tag and Tagging classes.
98
+
99
+ * Fix the :exclude option for find_tagged_with
100
+
101
+ [17 July 2007]
102
+
103
+ * Make the migration work on edge rails
104
+
105
+ [8 July 2007]
106
+
107
+ * find_options_for_tagged_with should not alter its arguments
108
+
109
+ [1 July 2007]
110
+
111
+ * Fix incorrect tagging when the case of the tag list is changed.
112
+
113
+ * Fix deprecated Tag.delimiter accessor.
114
+
115
+ [23 June 2007]
116
+
117
+ * Add validation to Tag model.
118
+
119
+ * find_options_for_tagged_with should always return a hash.
120
+
121
+ * find_tagged_with passing in no tags should return an empty array.
122
+
123
+ * Improve compatibility with PostgreSQL.
124
+
125
+ [21 June 2007]
126
+
127
+ * Remove extra .rb from generated migration file name.
128
+
129
+ [15 June 2007]
130
+
131
+ * Introduce TagList class.
132
+
133
+ * Various cleanups and improvements.
134
+
135
+ * Use TagList.delimiter now, not Tag.delimiter. Tag.delimiter will be removed at some stage.
136
+
137
+ [11 June 2007]
138
+
139
+ * Restructure the creation of the options for find_tagged_with [Thijs Cadier]
140
+
141
+ * Add an example migration with a generator.
142
+
143
+ * Add caching.
144
+
145
+ * Fix compatibility with Ruby < 1.8.6
146
+
147
+ [23 April 2007]
148
+
149
+ * Make tag_list to respect Tag.delimiter
150
+
151
+ [31 March 2007]
152
+
153
+ * Add Tag.delimiter accessor to change how tags are parsed.
154
+
155
+ * Fix :include => :tags when used with find_tagged_with
156
+
157
+ [7 March 2007]
158
+
159
+ * Fix tag_counts for SQLServer [Brad Young]
160
+
161
+ [21 Feb 2007]
162
+
163
+ * Use scoping instead of TagCountsExtension [Michael Schuerig]
164
+
165
+ [7 Jan 2007]
166
+
167
+ * Add :match_all to find_tagged_with [Michael Sheakoski]
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2006 Jonathan Viney
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,148 @@
1
+ = acts_as_taggable_on_steroids
2
+
3
+ If you find this plugin useful, please consider a donation to show your support!
4
+
5
+ http://www.paypal.com/cgi-bin/webscr?cmd=_send-money
6
+
7
+ Email address: jonathan.viney@gmail.com
8
+
9
+ == Instructions
10
+
11
+ This plugin is based on acts_as_taggable by DHH but includes extras
12
+ such as tests, smarter tag assignment, and tag cloud calculations.
13
+
14
+ == Installation
15
+
16
+ ruby script/plugin install http://svn.viney.net.nz/things/rails/plugins/acts_as_taggable_on_steroids
17
+
18
+ == Usage
19
+
20
+ === Prepare database
21
+
22
+ Generate and apply the migration:
23
+
24
+ ruby script/generate acts_as_taggable_migration
25
+ rake db:migrate
26
+
27
+ === Basic tagging
28
+
29
+ Let's suppose users have many posts and we want those posts to have tags.
30
+ The first step is to add +acts_as_taggable+ to the Post class:
31
+
32
+ class Post < ActiveRecord::Base
33
+ acts_as_taggable
34
+
35
+ belongs_to :user
36
+ end
37
+
38
+ We can now use the tagging methods provided by acts_as_taggable, <tt>#tag_list</tt> and <tt>#tag_list=</tt>. Both these
39
+ methods work like regular attribute accessors.
40
+
41
+ p = Post.find(:first)
42
+ p.tag_list # []
43
+ p.tag_list = "Funny, Silly"
44
+ p.save
45
+ p.tag_list # ["Funny", "Silly"]
46
+
47
+ You can also add or remove arrays of tags.
48
+
49
+ p.tag_list.add("Great", "Awful")
50
+ p.tag_list.remove("Funny")
51
+
52
+ === Finding tagged objects
53
+
54
+ To retrieve objects tagged with a certain tag, use find_tagged_with.
55
+
56
+ Post.find_tagged_with('Funny, Silly')
57
+
58
+ By default, find_tagged_with will find objects that have any of the given tags. To
59
+ find only objects that are tagged with all the given tags, use match_all.
60
+
61
+ Post.find_tagged_with('Funny, Silly', :match_all => true)
62
+
63
+ See <tt>ActiveRecord::Acts::Taggable::InstanceMethods</tt> for more methods and options.
64
+
65
+ === Tag cloud calculations
66
+
67
+ To construct tag clouds, the frequency of each tag needs to be calculated.
68
+ Because we specified +acts_as_taggable+ on the <tt>Post</tt> class, we can
69
+ get a calculation of all the tag counts by using <tt>Post.tag_counts</tt>. But what if we wanted a tag count for
70
+ an single user's posts? To achieve this we call tag_counts on the association:
71
+
72
+ User.find(:first).posts.tag_counts
73
+
74
+ A helper is included to assist with generating tag clouds. Include it in your helper file:
75
+
76
+ module ApplicationHelper
77
+ include TagsHelper
78
+ end
79
+
80
+ Here is an example that generates a tag cloud.
81
+
82
+ Controller:
83
+
84
+ class PostController < ApplicationController
85
+ def tag_cloud
86
+ @tags = Post.tag_counts
87
+ end
88
+ end
89
+
90
+ View:
91
+ <% tag_cloud @tags, %w(css1 css2 css3 css4) do |tag, css_class| %>
92
+ <%= link_to tag.name, { :action => :tag, :id => tag.name }, :class => css_class %>
93
+ <% end %>
94
+
95
+ CSS:
96
+
97
+ .css1 { font-size: 1.0em; }
98
+ .css2 { font-size: 1.2em; }
99
+ .css3 { font-size: 1.4em; }
100
+ .css4 { font-size: 1.6em; }
101
+
102
+ === Caching
103
+
104
+ It is useful to cache the list of tags to reduce the number of queries executed. To do this,
105
+ add a column named <tt>cached_tag_list</tt> to the model which is being tagged. The column should be long enough to hold
106
+ the full tag list and must have a default value of null, not an empty string.
107
+
108
+ class CachePostTagList < ActiveRecord::Migration
109
+ def self.up
110
+ add_column :posts, :cached_tag_list, :string
111
+ end
112
+ end
113
+
114
+ class Post < ActiveRecord::Base
115
+ acts_as_taggable
116
+
117
+ # The caching column defaults to cached_tag_list, but can be changed:
118
+ #
119
+ # set_cached_tag_list_column_name "my_caching_column_name"
120
+ end
121
+
122
+ The details of the caching are handled for you. Just continue to use the tag_list accessor as you normally would.
123
+ Note that the cached tag list will not be updated if you directly create Tagging objects or manually append to the
124
+ <tt>tags</tt> or <tt>taggings</tt> associations. To update the cached tag list you should call <tt>save_cached_tag_list</tt> manually.
125
+
126
+ === Delimiter
127
+
128
+ If you want to change the delimiter used to parse and present tags, set TagList.delimiter.
129
+ For example, to use spaces instead of commas, add the following to config/environment.rb:
130
+
131
+ TagList.delimiter = " "
132
+
133
+ You can also use a regexp as delimiter:
134
+
135
+ TagList.delimiter = /,|;/
136
+
137
+ The above code would parse the string and use ',' and ';' as delimiters.
138
+
139
+ === Unused tags
140
+
141
+ Set Tag.destroy_unused to remove tags when they are no longer being
142
+ used to tag any objects. Defaults to false.
143
+
144
+ Tag.destroy_unused = true
145
+
146
+ === Other
147
+
148
+ Problems, comments, and suggestions all welcome. jonathan.viney@gmail.com
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+ require 'rake/gempackagetask'
5
+
6
+ spec = eval(File.read("#{File.dirname(__FILE__)}/acts_as_taggable_on_steroids.gemspec"))
7
+ PKG_NAME = spec.name
8
+ PKG_VERSION = spec.version
9
+
10
+ Rake::GemPackageTask.new(spec) do |pkg|
11
+ pkg.need_zip = true
12
+ pkg.need_tar = true
13
+ end
14
+
15
+ desc 'Default: run unit tests.'
16
+ task :default => :test
17
+
18
+ desc 'Test the acts_as_taggable_on_steroids plugin.'
19
+ Rake::TestTask.new(:test) do |t|
20
+ t.libs << 'lib'
21
+ t.pattern = 'test/**/*_test.rb'
22
+ t.verbose = true
23
+ end
24
+
25
+ desc 'Generate documentation for the acts_as_taggable_on_steroids plugin.'
26
+ Rake::RDocTask.new(:rdoc) do |rdoc|
27
+ rdoc.rdoc_dir = 'rdoc'
28
+ rdoc.title = 'Acts As Taggable On Steroids'
29
+ rdoc.options << '--line-numbers' << '--inline-source'
30
+ rdoc.rdoc_files.include('README')
31
+ rdoc.rdoc_files.include('lib/**/*.rb')
32
+ end
@@ -0,0 +1,11 @@
1
+ class ActsAsTaggableMigrationGenerator < Rails::Generator::Base
2
+ def manifest
3
+ record do |m|
4
+ m.migration_template 'migration.rb', 'db/migrate'
5
+ end
6
+ end
7
+
8
+ def file_name
9
+ "acts_as_taggable_migration"
10
+ end
11
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.dirname(__FILE__) + '/rails/init'
@@ -0,0 +1,214 @@
1
+ module ActiveRecord #:nodoc:
2
+ module Acts #:nodoc:
3
+ module Taggable #:nodoc:
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ def acts_as_taggable
10
+ has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag
11
+ has_many :tags, :through => :taggings
12
+
13
+ before_save :save_cached_tag_list
14
+ after_save :save_tags
15
+
16
+ include ActiveRecord::Acts::Taggable::InstanceMethods
17
+ extend ActiveRecord::Acts::Taggable::SingletonMethods
18
+
19
+ alias_method_chain :reload, :tag_list
20
+ end
21
+
22
+ def cached_tag_list_column_name
23
+ "cached_tag_list"
24
+ end
25
+
26
+ def set_cached_tag_list_column_name(value = nil, &block)
27
+ define_attr_method :cached_tag_list_column_name, value, &block
28
+ end
29
+ end
30
+
31
+ module SingletonMethods
32
+ # Pass either a tag string, or an array of strings or tags
33
+ #
34
+ # Options:
35
+ # :exclude - Find models that are not tagged with the given tags
36
+ # :match_all - Find models that match all of the given tags, not just one
37
+ # :conditions - A piece of SQL conditions to add to the query
38
+ def find_tagged_with(*args)
39
+ options = find_options_for_find_tagged_with(*args)
40
+ options.blank? ? [] : find(:all, options)
41
+ end
42
+
43
+ # will_paginate's method_missing function wants to hit
44
+ # find_all_tagged_with if you call paginate_tagged_with, which is
45
+ # obviously suboptimal
46
+ def find_all_tagged_with(*args)
47
+ find_tagged_with(*args)
48
+ end
49
+
50
+ def find_options_for_find_tagged_with(tags, options = {})
51
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
52
+ options = options.dup
53
+
54
+ return {} if tags.empty?
55
+
56
+ conditions = []
57
+ conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
58
+
59
+ taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
60
+
61
+ if options.delete(:exclude)
62
+ conditions << <<-END
63
+ #{table_name}.id NOT IN
64
+ (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name}
65
+ INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id
66
+ WHERE #{tags_condition(tags)} AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})
67
+ END
68
+ else
69
+ if options.delete(:match_all)
70
+ conditions << <<-END
71
+ (SELECT COUNT(*) FROM #{Tagging.table_name}
72
+ INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id
73
+ WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)} AND
74
+ taggable_id = #{table_name}.id AND
75
+ #{tags_condition(tags)}) = #{tags.size}
76
+ END
77
+ else
78
+ conditions << tags_condition(tags, tags_alias)
79
+ end
80
+ end
81
+
82
+ { :select => "DISTINCT #{table_name}.*",
83
+ :joins => "INNER 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)} " +
84
+ "INNER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id",
85
+ :conditions => conditions.join(" AND ")
86
+ }.reverse_merge!(options)
87
+ end
88
+
89
+ # Calculate the tag counts for all tags.
90
+ #
91
+ # Options:
92
+ # :start_at - Restrict the tags to those created after a certain time
93
+ # :end_at - Restrict the tags to those created before a certain time
94
+ # :conditions - A piece of SQL conditions to add to the query
95
+ # :limit - The maximum number of tags to return
96
+ # :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
97
+ # :at_least - Exclude tags with a frequency less than the given value
98
+ # :at_most - Exclude tags with a frequency greater than the given value
99
+ def tag_counts(options = {})
100
+ Tag.find(:all, find_options_for_tag_counts(options))
101
+ end
102
+
103
+ # Find how many objects are tagged with a certain tag.
104
+ def count_by_tag(tag_name)
105
+ counts = tag_counts(:conditions => "tags.name = #{quote_value(tag_name)}")
106
+ counts[0].respond_to?(:count) ? counts[0].count : 0
107
+ end
108
+
109
+ def find_options_for_tag_counts(options = {})
110
+ options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit
111
+ options = options.dup
112
+
113
+ scope = scope(:find)
114
+ start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
115
+ end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
116
+
117
+ conditions = [
118
+ "#{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}",
119
+ sanitize_sql(options.delete(:conditions)),
120
+ scope && scope[:conditions],
121
+ start_at,
122
+ end_at
123
+ ]
124
+
125
+ conditions << type_condition unless descends_from_active_record?
126
+ conditions.compact!
127
+ conditions = conditions.join(' AND ')
128
+
129
+ joins = ["INNER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id"]
130
+ joins << "INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"
131
+ joins << scope[:joins] if scope && scope[:joins]
132
+
133
+ at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
134
+ at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
135
+ having = [at_least, at_most].compact.join(' AND ')
136
+ group_by = "#{Tag.table_name}.id, #{Tag.table_name}.name HAVING COUNT(*) > 0"
137
+ group_by << " AND #{having}" unless having.blank?
138
+
139
+ { :select => "#{Tag.table_name}.id, #{Tag.table_name}.name, COUNT(*) AS count",
140
+ :joins => joins.join(" "),
141
+ :conditions => conditions,
142
+ :group => group_by
143
+ }.reverse_merge!(options)
144
+ end
145
+
146
+ def caching_tag_list?
147
+ column_names.include?(cached_tag_list_column_name)
148
+ end
149
+
150
+ private
151
+ def tags_condition(tags, table_name = Tag.table_name)
152
+ condition = tags.map { |t| sanitize_sql(["#{table_name}.name LIKE ?", t]) }.join(" OR ")
153
+ "(" + condition + ")"
154
+ end
155
+ end
156
+
157
+ module InstanceMethods
158
+ def tag_list
159
+ return @tag_list if @tag_list
160
+
161
+ if self.class.caching_tag_list? and !(cached_value = send(self.class.cached_tag_list_column_name)).nil?
162
+ @tag_list = TagList.from(cached_value)
163
+ else
164
+ @tag_list = TagList.new(*tags.map(&:name))
165
+ end
166
+ end
167
+
168
+ def tag_list=(value)
169
+ @tag_list = TagList.from(value)
170
+ end
171
+
172
+ def save_cached_tag_list
173
+ if self.class.caching_tag_list?
174
+ self[self.class.cached_tag_list_column_name] = tag_list.to_s
175
+ end
176
+ end
177
+
178
+ def save_tags
179
+ return unless @tag_list
180
+
181
+ new_tag_names = @tag_list - tags.map(&:name)
182
+ old_tags = tags.reject { |tag| @tag_list.include?(tag.name) }
183
+
184
+ self.class.transaction do
185
+ if old_tags.any?
186
+ taggings.find(:all, :conditions => ["tag_id IN (?)", old_tags.map(&:id)]).each(&:destroy)
187
+ taggings.reset
188
+ end
189
+
190
+ new_tag_names.each do |new_tag_name|
191
+ tags << Tag.find_or_create_with_like_by_name(new_tag_name)
192
+ end
193
+ end
194
+
195
+ true
196
+ end
197
+
198
+ # Calculate the tag counts for the tags used by this model.
199
+ #
200
+ # The possible options are the same as the tag_counts class method, excluding :conditions.
201
+ def tag_counts(options = {})
202
+ self.class.tag_counts({ :conditions => self.class.send(:tags_condition, tag_list) }.reverse_merge!(options))
203
+ end
204
+
205
+ def reload_with_tag_list(*args) #:nodoc:
206
+ @tag_list = nil
207
+ reload_without_tag_list(*args)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+
214
+ ActiveRecord::Base.send(:include, ActiveRecord::Acts::Taggable)