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