taxonomy 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.rdoc +28 -0
  3. data/Rakefile +31 -0
  4. data/lib/generators/taxonomy/migration/migration_generator.rb +39 -0
  5. data/lib/generators/taxonomy/migration/templates/active_record/migration.rb +36 -0
  6. data/lib/tasks/taxonomy_tasks.rake +4 -0
  7. data/lib/taxonomy.rb +25 -0
  8. data/lib/taxonomy/group_helper.rb +12 -0
  9. data/lib/taxonomy/has_tagger.rb +52 -0
  10. data/lib/taxonomy/has_taxonomy.rb +502 -0
  11. data/lib/taxonomy/tag.rb +485 -0
  12. data/lib/taxonomy/tag_list.rb +97 -0
  13. data/lib/taxonomy/tagging.rb +12 -0
  14. data/lib/taxonomy/tags_helper.rb +13 -0
  15. data/lib/taxonomy/version.rb +3 -0
  16. data/spec/dummy/Rakefile +7 -0
  17. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  18. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  19. data/spec/dummy/app/models/altered_inheriting_taggable_model.rb +3 -0
  20. data/spec/dummy/app/models/inheriting_taggable_model.rb +2 -0
  21. data/spec/dummy/app/models/other_taggable_model.rb +4 -0
  22. data/spec/dummy/app/models/post.rb +2 -0
  23. data/spec/dummy/app/models/taggable_model.rb +6 -0
  24. data/spec/dummy/app/models/taggable_user.rb +3 -0
  25. data/spec/dummy/app/models/treed_model.rb +3 -0
  26. data/spec/dummy/app/models/untaggable_model.rb +2 -0
  27. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  28. data/spec/dummy/config.ru +4 -0
  29. data/spec/dummy/config/application.rb +45 -0
  30. data/spec/dummy/config/boot.rb +10 -0
  31. data/spec/dummy/config/database.yml +19 -0
  32. data/spec/dummy/config/environment.rb +5 -0
  33. data/spec/dummy/config/environments/development.rb +30 -0
  34. data/spec/dummy/config/environments/production.rb +60 -0
  35. data/spec/dummy/config/environments/test.rb +39 -0
  36. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  37. data/spec/dummy/config/initializers/inflections.rb +10 -0
  38. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  39. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  40. data/spec/dummy/config/initializers/session_store.rb +8 -0
  41. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  42. data/spec/dummy/config/locales/en.yml +5 -0
  43. data/spec/dummy/config/routes.rb +58 -0
  44. data/spec/dummy/db/migrate/20111221004133_create_posts.rb +8 -0
  45. data/spec/dummy/db/migrate/20111221023928_taxonomy_migration.rb +35 -0
  46. data/spec/dummy/db/migrate/20111221024100_create_bulk.rb +18 -0
  47. data/spec/dummy/db/schema.rb +65 -0
  48. data/spec/dummy/db/test.sqlite3 +0 -0
  49. data/spec/dummy/log/test.log +100915 -0
  50. data/spec/dummy/public/404.html +26 -0
  51. data/spec/dummy/public/422.html +26 -0
  52. data/spec/dummy/public/500.html +26 -0
  53. data/spec/dummy/public/favicon.ico +0 -0
  54. data/spec/dummy/script/rails +6 -0
  55. data/spec/factories/posts.rb +6 -0
  56. data/spec/generators/taxonomy/migration/migration_generator_spec.rb +22 -0
  57. data/spec/models/post_spec.rb +5 -0
  58. data/spec/spec_helper.rb +30 -0
  59. data/spec/taxonomy/group_helper_spec.rb +21 -0
  60. data/spec/taxonomy/has_tagger_spec.rb +113 -0
  61. data/spec/taxonomy/has_taxonomy_spec.rb +226 -0
  62. data/spec/taxonomy/tag_list_spec.rb +70 -0
  63. data/spec/taxonomy/tag_spec.rb +462 -0
  64. data/spec/taxonomy/taggable_spec.rb +262 -0
  65. data/spec/taxonomy/tagger_spec.rb +40 -0
  66. data/spec/taxonomy/tagging_spec.rb +25 -0
  67. data/spec/taxonomy/tags_helper_spec.rb +29 -0
  68. metadata +225 -0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2011 Seth Faxon
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.rdoc ADDED
@@ -0,0 +1,28 @@
1
+ = Taxonomy
2
+
3
+ this gem is an active record plugin. think of it as a merger of http://rubygems.org/gems/acts-as-taggable-on and http://rubygems.org/gems/awesome_nested_set because, well it is. With some glue code and updates to the models.
4
+
5
+
6
+ == Setup Rails 3.x
7
+
8
+ Add to Gemfile:
9
+
10
+ gem 'taxonomy'
11
+
12
+ Run migration:
13
+
14
+ rails generate taxonomy:migration
15
+ rake db:migrate
16
+
17
+
18
+ == Usage
19
+
20
+ to setup plain tags and a treed set of categories
21
+
22
+ has_taxonomy_on :tags, {:treed => [:categories]}
23
+
24
+
25
+ Tag.roots.where(:context => "category")
26
+
27
+
28
+ Tag.find_context_with_slug!("category", params[:slug])
data/Rakefile ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rake/dsl_definition'
9
+ require 'rdoc/task'
10
+ rescue LoadError
11
+ require 'rdoc/rdoc'
12
+ require 'rake/rdoctask'
13
+ RDoc::Task = Rake::RDocTask
14
+ end
15
+
16
+ RDoc::Task.new(:rdoc) do |rdoc|
17
+ rdoc.rdoc_dir = 'rdoc'
18
+ rdoc.title = 'Taxonomy'
19
+ rdoc.options << '--line-numbers'
20
+ rdoc.rdoc_files.include('README.rdoc')
21
+ rdoc.rdoc_files.include('lib/**/*.rb')
22
+ end
23
+
24
+ APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
25
+ # load 'rails/tasks/engine.rake'
26
+
27
+ Bundler::GemHelper.install_tasks
28
+
29
+ Dir["#{File.dirname(__FILE__)}/tasks/*.rake"].sort.each { |ext| load ext }
30
+
31
+ task :default => :spec
@@ -0,0 +1,39 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ module Taxonomy
5
+ class MigrationGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+
8
+ desc "Generates migration for Tag and Tagging models"
9
+
10
+ def self.orm
11
+ Rails::Generators.options[:rails][:orm]
12
+ end
13
+
14
+ def self.source_root
15
+ File.join(File.dirname(__FILE__), 'templates', (orm.to_s unless orm.class.eql?(String)) )
16
+ end
17
+
18
+ def self.orm_has_migration?
19
+ [:active_record].include? orm
20
+ end
21
+
22
+ def self.next_migration_number(dirname)
23
+ if ActiveRecord::Base.timestamped_migrations
24
+ migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
25
+ migration_number += 1
26
+ migration_number.to_s
27
+ else
28
+ "%.3d" % (current_migration_number(dirname) + 1)
29
+ end
30
+ end
31
+
32
+ def create_migration_file
33
+ if self.class.orm_has_migration?
34
+ migration_template 'migration.rb', 'db/migrate/taxonomy_migration'
35
+ end
36
+ end
37
+ end
38
+ end
39
+
@@ -0,0 +1,36 @@
1
+ class TaxonomyMigration < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :tags do |t|
4
+ t.integer :parent_id
5
+ t.integer :lft
6
+ t.integer :rgt
7
+ t.string :name
8
+ t.string :context
9
+ t.string :slug
10
+ end
11
+
12
+ create_table :taggings do |t|
13
+ t.references :tag
14
+
15
+ # You should make sure that the column created is
16
+ # long enough to store the required class names.
17
+ t.references :taggable, :polymorphic => true
18
+ t.references :tagger, :polymorphic => true
19
+
20
+ t.datetime :created_at
21
+ end
22
+
23
+ add_index :tags, [:parent_id]
24
+ add_index :tags, [:lft, :rgt]
25
+ add_index :tags, :context
26
+ add_index :tags, :slug
27
+
28
+ add_index :taggings, :tag_id
29
+ add_index :taggings, [:taggable_id, :taggable_type, :context]
30
+ end
31
+
32
+ def self.down
33
+ drop_table :taggings
34
+ drop_table :tags
35
+ end
36
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :taxonomy do
3
+ # # Task goes here
4
+ # end
data/lib/taxonomy.rb ADDED
@@ -0,0 +1,25 @@
1
+ module Taxonomy
2
+ mattr_accessor :nested_set_options
3
+ @@nested_set_options = { :parent_column => 'parent_id',
4
+ :left_column => 'lft',
5
+ :right_column => 'rgt',
6
+ :dependent => :destroy
7
+ }
8
+
9
+ def self.setup
10
+ yield self
11
+ @@nested_set_options.symbolize_keys!
12
+ end
13
+ end
14
+
15
+ require 'taxonomy/group_helper'
16
+ require 'taxonomy/has_taxonomy'
17
+ require 'taxonomy/has_tagger'
18
+ require 'taxonomy/tag'
19
+ require 'taxonomy/tag_list'
20
+ require 'taxonomy/tags_helper'
21
+ require 'taxonomy/tagging'
22
+
23
+ ActiveRecord::Base.send :include, ActiveRecord::Acts::Taxonomy
24
+ ActiveRecord::Base.send :include, ActiveRecord::Acts::Tagger
25
+ ActionView::Base.send :include, TagsHelper if defined?(ActionView::Base)
@@ -0,0 +1,12 @@
1
+ module ActiveRecord
2
+ module Acts
3
+ module Taxonomy
4
+ module GroupHelper
5
+ # all column names are necessary for PostgreSQL group clause
6
+ def grouped_column_names_for(object)
7
+ object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,52 @@
1
+ module ActiveRecord
2
+ module Acts
3
+ module Tagger
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+ end
7
+
8
+ module ClassMethods
9
+ def acts_as_tagger(opts={})
10
+ has_many :owned_taggings, opts.merge(:as => :tagger, :dependent => :destroy,
11
+ :include => :tag, :class_name => "Tagging")
12
+ has_many :owned_tags, :through => :owned_taggings, :source => :tag, :uniq => true
13
+ include ActiveRecord::Acts::Tagger::InstanceMethods
14
+ extend ActiveRecord::Acts::Tagger::SingletonMethods
15
+ end
16
+
17
+ def is_tagger?
18
+ false
19
+ end
20
+ end
21
+
22
+ module InstanceMethods
23
+ def self.included(base)
24
+ end
25
+
26
+ def tag(taggable, opts={})
27
+ opts.reverse_merge!(:force => true)
28
+
29
+ return false unless taggable.respond_to?(:is_taggable?) && taggable.is_taggable?
30
+ raise "You need to specify a tag context using :on" unless opts.has_key?(:on)
31
+ raise "You need to specify some tags using :with" unless opts.has_key?(:with)
32
+ raise "No context :#{opts[:on]} defined in #{taggable.class.to_s}" unless
33
+ ( opts[:force] || taggable.tag_types.include?(opts[:on]) )
34
+
35
+ taggable.set_tag_list_on(opts[:on].to_s, opts[:with], self)
36
+ taggable.save
37
+ end
38
+
39
+ def is_tagger?
40
+ self.class.is_tagger?
41
+ end
42
+ end
43
+
44
+ module SingletonMethods
45
+ def is_tagger?
46
+ true
47
+ end
48
+ end
49
+
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,502 @@
1
+ module ActiveRecord
2
+ module Acts
3
+ module Taxonomy
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ def taggable?
10
+ false
11
+ end
12
+
13
+ def acts_as_taggable
14
+ has_taxonomy_on :tags
15
+ end
16
+
17
+ def has_taxonomy_on(*args)
18
+ treed_args = []
19
+ args.flatten! if args
20
+ args.compact! if args
21
+ # pull out treed args
22
+ args.each do |tag_type|
23
+ if tag_type.is_a?(Hash)
24
+ treed_tags = tag_type.values.flatten
25
+ treed_tags.compact!
26
+
27
+ treed_tags.each do |tree|
28
+ treed_args << tree
29
+ end
30
+ args.delete(tag_type)
31
+ end
32
+ end
33
+
34
+ if !(args & treed_args).empty?
35
+ raise "duplicate taxonomy keys"
36
+ end
37
+ args.concat(treed_args)
38
+ args.flatten!
39
+ args.map!{|x| x.to_s} # convert to strings
40
+ treed_args.map!{|x| x.to_s}
41
+ args.each do |tag_type|
42
+ # use aliased_join_table_name for context condition so that sphinx can join multiple
43
+ # tag references from same model without getting an ambiguous column error
44
+ class_eval do
45
+ has_many "#{tag_type.singularize}_taggings".to_sym, :as => :taggable, :dependent => :destroy,
46
+ :include => :tag, :conditions => ['tags.context = ?',tag_type.singularize], :class_name => "Tagging"
47
+ has_many "#{tag_type}".to_sym, :through => "#{tag_type.singularize}_taggings".to_sym, :source => :tag
48
+ end
49
+
50
+ class_eval <<-RUBY
51
+ def self.taggable?
52
+ true
53
+ end
54
+
55
+ def self.#{tag_type}_treed?
56
+ treed_list_on?("#{tag_type}")
57
+ end
58
+
59
+ def self.caching_#{tag_type.singularize}_list?
60
+ caching_tag_list_on?("#{tag_type}")
61
+ end
62
+
63
+ def self.#{tag_type.singularize}_counts(options={})
64
+ tag_counts_on('#{tag_type}',options)
65
+ end
66
+
67
+ def #{tag_type.singularize}_list
68
+ tag_list_on('#{tag_type}')
69
+ end
70
+
71
+ def #{tag_type.singularize}_list=(new_tags)
72
+ set_tag_list_on('#{tag_type}',new_tags)
73
+ end
74
+
75
+ def #{tag_type.singularize}_counts(options = {})
76
+ tag_counts_on('#{tag_type}',options)
77
+ end
78
+
79
+ def #{tag_type}_from(owner)
80
+ tag_list_on('#{tag_type}', owner)
81
+ end
82
+
83
+ def find_related_#{tag_type}(options = {})
84
+ related_tags_for('#{tag_type}', self.class, options)
85
+ end
86
+ alias_method :find_related_on_#{tag_type}, :find_related_#{tag_type}
87
+
88
+ def find_related_#{tag_type}_for(klass, options = {})
89
+ related_tags_for('#{tag_type}', klass, options)
90
+ end
91
+
92
+ def find_matching_contexts(search_context, result_context, options = {})
93
+ matching_contexts_for(search_context.to_s, result_context.to_s, self.class, options)
94
+ end
95
+
96
+ def find_matching_contexts_for(klass, search_context, result_context, options = {})
97
+ matching_contexts_for(search_context.to_s, result_context.to_s, klass, options)
98
+ end
99
+
100
+ def top_#{tag_type}(limit = 10)
101
+ tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
102
+ end
103
+
104
+ def self.top_#{tag_type}(limit = 10)
105
+ tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
106
+ end
107
+ RUBY
108
+
109
+ end
110
+ if respond_to?(:tag_types)
111
+ write_inheritable_attribute( :tag_types, (tag_types + args).uniq )
112
+ write_inheritable_attribute( :treed_tag_types, treed_args)
113
+ else
114
+ class_eval do
115
+ write_inheritable_attribute(:tag_types, args.uniq)
116
+ class_inheritable_reader :tag_types
117
+
118
+ write_inheritable_attribute( :treed_tag_types, treed_args.uniq)
119
+ class_inheritable_reader :treed_tag_types
120
+
121
+ has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag
122
+ has_many :base_tags, :class_name => "Tag", :through => :taggings, :source => :tag
123
+
124
+ attr_writer :custom_contexts
125
+
126
+ before_save :save_cached_tag_list
127
+ after_save :save_tags
128
+
129
+ if respond_to?(:scope)
130
+ scope :tagged_with, lambda{ |*args|
131
+ find_options_for_find_tagged_with(*args)
132
+ }
133
+ end
134
+
135
+ end
136
+
137
+ include ActiveRecord::Acts::Taxonomy::InstanceMethods
138
+ extend ActiveRecord::Acts::Taxonomy::SingletonMethods
139
+ alias_method_chain :reload, :tag_list
140
+ end
141
+ end
142
+ end
143
+
144
+ module SingletonMethods
145
+ include ActiveRecord::Acts::Taxonomy::GroupHelper
146
+ # Pass either a tag string, or an array of strings or tags
147
+ #
148
+ # Options:
149
+ # :any - find models that match any of the given tags
150
+ # :exclude - Find models that are not tagged with the given tags
151
+ # :match_all - Find models that match all of the given tags, not just one
152
+ # :conditions - A piece of SQL conditions to add to the query
153
+ # :on - scopes the find to a context
154
+ def find_tagged_with(context, name, *args)
155
+ find_opts = {:on => context}
156
+ case args #fixme: args will probably always be an array
157
+ when Array
158
+ if args.first.is_a?(Hash)
159
+ find_opts.merge!(args.first)
160
+ end
161
+ end
162
+ options = find_options_for_find_tagged_with(name, find_opts)
163
+ options.blank? ? [] : find(:all,options)
164
+ end
165
+
166
+ def caching_tag_list_on?(context)
167
+ column_names.include?("cached_#{context.to_s.singularize}_list")
168
+ end
169
+
170
+ def treed_list_on?(context)
171
+ treed_tag_types.include?(context)
172
+ end
173
+
174
+ def tag_counts_on(context, options = {})
175
+ Tag.all(find_options_for_tag_counts(options.merge({:on => context.to_s.singularize})))
176
+ end
177
+
178
+ def all_tag_counts(options = {})
179
+ Tag.all(find_options_for_tag_counts(options))
180
+ end
181
+
182
+ def find_options_for_find_tagged_with(tags, options = {})
183
+ tag_list = TagList.from(tags)
184
+
185
+ return {} if tag_list.empty?
186
+
187
+ joins = []
188
+ conditions = []
189
+
190
+ context = options.delete(:on)
191
+ context = context.to_s.singularize
192
+
193
+ if options.delete(:exclude)
194
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
195
+ conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND (#{tags_conditions}) WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
196
+
197
+ elsif options.delete(:any)
198
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
199
+ conditions << "#{table_name}.#{primary_key} IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND (#{tags_conditions}) WHERE #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
200
+
201
+ else
202
+ tags = Tag.named_any(context, tag_list)
203
+ return { :conditions => "1 = 0" } unless tags.length == tag_list.length
204
+
205
+ tags.each do |tag|
206
+ safe_tag = tag.name.gsub(/[^a-zA-Z0-9]/, '')
207
+ prefix = "#{safe_tag}_#{rand(1024)}"
208
+
209
+ taggings_alias = "#{table_name}_taggings_#{prefix}"
210
+
211
+ tagging_join = "JOIN #{Tagging.table_name} #{taggings_alias}" +
212
+ " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
213
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
214
+ " AND #{taggings_alias}.tag_id = #{tag.id}"
215
+ # tagging_join << " AND " + sanitize_sql(["tags.context = ?", context.to_s]) if context
216
+
217
+ joins << tagging_join
218
+ end
219
+ end
220
+
221
+ taggings_alias, tags_alias = "#{table_name}_taggings_group", "#{table_name}_tags_group"
222
+
223
+ if options.delete(:match_all)
224
+ joins << "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias}" +
225
+ " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
226
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
227
+
228
+ group = "#{grouped_column_names_for(self)} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
229
+ end
230
+
231
+ { :joins => joins.join(" "),
232
+ :group => group,
233
+ :conditions => conditions.join(" AND "),
234
+ :readonly => false }.update(options)
235
+ end
236
+
237
+ # Calculate the tag counts for all tags.
238
+ #
239
+ # Options:
240
+ # :start_at - Restrict the tags to those created after a certain time
241
+ # :end_at - Restrict the tags to those created before a certain time
242
+ # :conditions - A piece of SQL conditions to add to the query
243
+ # :limit - The maximum number of tags to return
244
+ # :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
245
+ # :at_least - Exclude tags with a frequency less than the given value
246
+ # :at_most - Exclude tags with a frequency greater than the given value
247
+ # :on - Scope the find to only include a certain context
248
+ def find_options_for_tag_counts(options = {})
249
+ options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on, :id
250
+
251
+ # scope = scope(:find)
252
+
253
+ start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
254
+ end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
255
+
256
+ taggable_type = sanitize_sql(["#{Tagging.table_name}.taggable_type = ?", base_class.name])
257
+ taggable_id = sanitize_sql(["#{Tagging.table_name}.taggable_id = ?", options.delete(:id)]) if options[:id]
258
+ options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
259
+
260
+ conditions = [
261
+ taggable_type,
262
+ taggable_id,
263
+ options[:conditions],
264
+ start_at,
265
+ end_at
266
+ ]
267
+
268
+ conditions = conditions.compact.join(' AND ')
269
+ # conditions = merge_conditions(conditions, scope[:conditions]) if scope
270
+
271
+ joins = ["LEFT OUTER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id"]
272
+ joins << sanitize_sql(["AND #{Tag.table_name}.context = ?",options.delete(:on).to_s]) unless options[:on].nil?
273
+ joins << " INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"
274
+
275
+ unless descends_from_active_record?
276
+ # Current model is STI descendant, so add type checking to the join condition
277
+ joins << " AND #{table_name}.#{inheritance_column} = '#{name}'"
278
+ end
279
+
280
+ # Based on a proposed patch by donV to ActiveRecord Base
281
+ # This is needed because merge_joins and construct_join are private in ActiveRecord Base
282
+ # if scope && scope[:joins]
283
+ # case scope[:joins]
284
+ # when Array
285
+ # scope_joins = scope[:joins].flatten
286
+ # strings = scope_joins.select{|j| j.is_a? String}
287
+ # joins << strings.join(' ') + " "
288
+ # symbols = scope_joins - strings
289
+ # join_dependency = ActiveRecord::Associations::ClassMethods::InnerJoinDependency.new(self, symbols, nil)
290
+ # joins << " #{join_dependency.join_associations.collect { |assoc| assoc.association_join }.join} "
291
+ # joins.flatten!
292
+ # when Symbol, Hash
293
+ # join_dependency = ActiveRecord::Associations::ClassMethods::InnerJoinDependency.new(self, scope[:joins], nil)
294
+ # joins << " #{join_dependency.join_associations.collect { |assoc| assoc.association_join }.join} "
295
+ # when String
296
+ # joins << scope[:joins]
297
+ # end
298
+ # end
299
+
300
+ at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
301
+ at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
302
+ having = [at_least, at_most].compact.join(' AND ')
303
+ if joins.include?(".context") # if :on is passed
304
+ group_by = "#{grouped_column_names_for(Tag)} HAVING COUNT(*) > 0"
305
+ else
306
+ group_by = "tags.name HAVING COUNT(*) > 0"
307
+ end
308
+ group_by << " AND #{having}" unless having.blank?
309
+
310
+ { :select => "#{Tag.table_name}.*, COUNT(*) AS count",
311
+ :joins => joins.join(" "),
312
+ :conditions => conditions,
313
+ :group => group_by,
314
+ :limit => options[:limit],
315
+ :order => options[:order]
316
+ }
317
+ end
318
+
319
+ def is_taggable?
320
+ true
321
+ end
322
+ end
323
+
324
+ module InstanceMethods
325
+ include ActiveRecord::Acts::Taxonomy::GroupHelper
326
+
327
+ def custom_contexts
328
+ @custom_contexts ||= []
329
+ end
330
+
331
+ def is_taggable?
332
+ self.class.is_taggable?
333
+ end
334
+
335
+ def add_custom_context(value)
336
+ custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) || self.class.tag_types.map(&:to_s).include?(value.to_s)
337
+ end
338
+
339
+ def tag_list_on(context, owner = nil)
340
+ context = context.to_s.singularize
341
+ add_custom_context(context)
342
+ cache = tag_list_cache_on(context)
343
+ return owner ? cache[owner] : cache[owner] if cache[owner]
344
+
345
+ if !owner && self.class.caching_tag_list_on?(context) and !(cached_value = cached_tag_list_on(context)).nil?
346
+ cache[owner] = TagList.from(cached_tag_list_on(context))
347
+ else
348
+ cache[owner] = TagList.new(*tags_on(context, owner).map(&:name))
349
+ end
350
+ end
351
+
352
+ def all_tags_list_on(context)
353
+ context = context.to_s.singularize
354
+ variable_name = "@all_#{context}_list"
355
+ return instance_variable_get(variable_name) if instance_variable_get(variable_name)
356
+ instance_variable_set(variable_name, TagList.new(all_tags_on(context).map(&:name)).freeze)
357
+ end
358
+
359
+ def all_tags_on(context)
360
+ context = context.to_s.singularize
361
+ opts = {:conditions => ["#{Tag.table_name}.context = ?", context.to_s]}
362
+ base_tags.find(:all, opts.merge(:order => "#{Tagging.table_name}.created_at"))
363
+ end
364
+
365
+ def tags_on(context, owner = nil)
366
+ context = context.to_s.singularize
367
+ if owner
368
+ base_tags.where("#{Tag.table_name}.context = ? AND #{Tagging.table_name}.tagger_id = ? AND #{Tagging.table_name}.tagger_type = ?",
369
+ context.to_s, owner.id, owner.class.to_s)
370
+ else
371
+ base_tags.where("#{Tag.table_name}.context = ? AND #{Tagging.table_name}.tagger_id IS NULL", context.to_s)
372
+ end
373
+ end
374
+
375
+ def cached_tag_list_on(context)
376
+ self["cached_#{context.to_s.singularize}_list"]
377
+ end
378
+
379
+ def tag_list_cache_on(context)
380
+ variable_name = "@#{context.to_s.singularize}_list"
381
+ cache = instance_variable_get(variable_name)
382
+ instance_variable_set(variable_name, cache = {}) unless cache
383
+ cache
384
+ end
385
+
386
+ def set_tag_list_on(context, new_list, tagger = nil)
387
+ context = context.to_s.singularize
388
+ tag_list_cache_on(context)[tagger] = TagList.from(new_list)
389
+ add_custom_context(context)
390
+ end
391
+
392
+ def tag_counts_on(context, options={})
393
+ self.class.tag_counts_on(context.to_s.singularize, options.merge(:id => id))
394
+ end
395
+
396
+ def related_tags_for(context, klass, options = {})
397
+ search_conditions = related_search_options(context.to_s.singularize, klass, options)
398
+
399
+ klass.find(:all, search_conditions)
400
+ end
401
+
402
+ def related_search_options(context, klass, options = {})
403
+ tags_to_find = tags_on(context.to_s.singularize).collect { |t| t.name }
404
+
405
+ exclude_self = "#{klass.table_name}.id != #{id} AND" if self.class == klass
406
+
407
+ { :select => "#{klass.table_name}.*, COUNT(#{Tag.table_name}.id) AS count",
408
+ :from => "#{klass.table_name}, #{Tag.table_name}, #{Tagging.table_name}",
409
+ :conditions => ["#{exclude_self} #{klass.table_name}.id = #{Tagging.table_name}.taggable_id AND #{Tagging.table_name}.taggable_type = '#{klass.to_s}' AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND #{Tag.table_name}.name IN (?)", tags_to_find],
410
+ :group => grouped_column_names_for(klass),
411
+ :order => "count DESC"
412
+ }.update(options)
413
+ end
414
+
415
+ def matching_contexts_for(search_context, result_context, klass, options = {})
416
+ search_conditions = matching_context_search_options(search_context, result_context, klass, options)
417
+
418
+ klass.find(:all, search_conditions)
419
+ end
420
+
421
+ def matching_context_search_options(search_context, result_context, klass, options = {})
422
+ search_context = search_context.to_s.singularize
423
+ result_context = result_context.to_s.singularize
424
+ tags_to_find = tags_on(search_context).collect { |t| t.name }
425
+
426
+ exclude_self = "#{klass.table_name}.id != #{id} AND" if self.class == klass
427
+
428
+ { :select => "#{klass.table_name}.*, COUNT(#{Tag.table_name}.id) AS count",
429
+ :from => "#{klass.table_name}, #{Tag.table_name}, #{Tagging.table_name}",
430
+ :conditions => ["#{exclude_self} #{klass.table_name}.id = #{Tagging.table_name}.taggable_id AND #{Tagging.table_name}.taggable_type = '#{klass.to_s}' AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id AND #{Tag.table_name}.name IN (?) AND #{Tag.table_name}.context = ?", tags_to_find, result_context],
431
+ :group => grouped_column_names_for(klass),
432
+ :order => "count DESC"
433
+ }.update(options)
434
+ end
435
+
436
+ def save_cached_tag_list
437
+ self.class.tag_types.map(&:to_s).each do |tag_type|
438
+ if self.class.send("caching_#{tag_type.singularize}_list?")
439
+ self["cached_#{tag_type.singularize}_list"] = tag_list_cache_on(tag_type.singularize).to_a.flatten.compact.join(', ')
440
+ end
441
+ end
442
+ end
443
+
444
+ def save_tags
445
+ contexts = custom_contexts + self.class.tag_types.map(&:to_s)
446
+ transaction do
447
+ contexts.each do |context|
448
+ context = context.to_s.singularize
449
+
450
+ cache = tag_list_cache_on(context)
451
+
452
+ cache.each do |owner, list|
453
+ new_tags = Tag.find_or_create_all_with_like_by_name(context, list.uniq)
454
+ taggings = Tagging.joins(:tag).where(:taggable_id => self.id, :taggable_type => self.class.base_class.to_s, "tags.context" => context ).all
455
+
456
+ # Destroy old taggings:
457
+ if owner
458
+ old_tags = tags_on(context, owner) - new_tags
459
+
460
+ old_taggings = Tagging.joins(:tag).where(:taggable_id => self.id,
461
+ :taggable_type => self.class.base_class.to_s,
462
+ :tag_id => old_tags,
463
+ :tagger_id => owner.id,
464
+ :tagger_type => owner.class.to_s,
465
+ "tags.context" => context)
466
+
467
+ Tagging.destroy_all :id => old_taggings.map(&:id)
468
+ else
469
+ old_tags = tags_on(context) - new_tags
470
+ base_tags.delete(*old_tags)
471
+ end
472
+
473
+ new_tags.reject! { |tag| taggings.any? { |tagging|
474
+ tagging.tag_id == tag.id &&
475
+ tagging.tagger_id == (owner ? owner.id : nil) &&
476
+ tagging.tagger_type == (owner ? owner.class.to_s : nil) &&
477
+ tagging.tag.context == context
478
+ }
479
+ }
480
+
481
+ # create new taggings:
482
+ new_tags.each do |tag|
483
+ Tagging.create!(:tag_id => tag.id, :tagger => owner, :taggable => self)
484
+ end
485
+ end
486
+ end
487
+ end
488
+
489
+ true
490
+ end
491
+
492
+ def reload_with_tag_list(*args)
493
+ self.class.tag_types.each do |tag_type|
494
+ instance_variable_set("@#{tag_type.to_s.singularize}_list", nil)
495
+ end
496
+
497
+ reload_without_tag_list(*args)
498
+ end
499
+ end
500
+ end
501
+ end
502
+ end