acts_as_taggable_on 3.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +9 -0
  5. data/Appraisals +7 -0
  6. data/Gemfile +5 -0
  7. data/Guardfile +5 -0
  8. data/LICENSE.md +20 -0
  9. data/README.md +309 -0
  10. data/Rakefile +13 -0
  11. data/UPGRADING +7 -0
  12. data/acts_as_taggable_on.gemspec +35 -0
  13. data/db/migrate/1_acts_as_taggable_on_migration.rb +30 -0
  14. data/db/migrate/2_add_missing_unique_indices.rb +21 -0
  15. data/gemfiles/rails_3.gemfile +8 -0
  16. data/gemfiles/rails_4.gemfile +8 -0
  17. data/lib/acts_as_taggable_on.rb +61 -0
  18. data/lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb +82 -0
  19. data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +187 -0
  20. data/lib/acts_as_taggable_on/acts_as_taggable_on/compatibility.rb +34 -0
  21. data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +394 -0
  22. data/lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb +37 -0
  23. data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +135 -0
  24. data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +84 -0
  25. data/lib/acts_as_taggable_on/engine.rb +6 -0
  26. data/lib/acts_as_taggable_on/tag.rb +119 -0
  27. data/lib/acts_as_taggable_on/tag_list.rb +101 -0
  28. data/lib/acts_as_taggable_on/taggable.rb +105 -0
  29. data/lib/acts_as_taggable_on/tagger.rb +76 -0
  30. data/lib/acts_as_taggable_on/tagging.rb +34 -0
  31. data/lib/acts_as_taggable_on/tags_helper.rb +15 -0
  32. data/lib/acts_as_taggable_on/utils.rb +34 -0
  33. data/lib/acts_as_taggable_on/version.rb +4 -0
  34. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +265 -0
  35. data/spec/acts_as_taggable_on/acts_as_tagger_spec.rb +114 -0
  36. data/spec/acts_as_taggable_on/caching_spec.rb +77 -0
  37. data/spec/acts_as_taggable_on/related_spec.rb +143 -0
  38. data/spec/acts_as_taggable_on/single_table_inheritance_spec.rb +187 -0
  39. data/spec/acts_as_taggable_on/tag_list_spec.rb +126 -0
  40. data/spec/acts_as_taggable_on/tag_spec.rb +211 -0
  41. data/spec/acts_as_taggable_on/taggable_spec.rb +623 -0
  42. data/spec/acts_as_taggable_on/tagger_spec.rb +137 -0
  43. data/spec/acts_as_taggable_on/tagging_spec.rb +28 -0
  44. data/spec/acts_as_taggable_on/tags_helper_spec.rb +44 -0
  45. data/spec/acts_as_taggable_on/utils_spec.rb +21 -0
  46. data/spec/bm.rb +52 -0
  47. data/spec/database.yml.sample +19 -0
  48. data/spec/models.rb +58 -0
  49. data/spec/schema.rb +65 -0
  50. data/spec/spec_helper.rb +87 -0
  51. metadata +248 -0
@@ -0,0 +1,30 @@
1
+ class ActsAsTaggableOnMigration < 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
+
10
+ # You should make sure that the column created is
11
+ # long enough to store the required class names.
12
+ t.references :taggable, :polymorphic => true
13
+ t.references :tagger, :polymorphic => true
14
+
15
+ # Limit is created to prevent MySQL error on index
16
+ # length for MyISAM table type: http://bit.ly/vgW2Ql
17
+ t.string :context, :limit => 128
18
+
19
+ t.datetime :created_at
20
+ end
21
+
22
+ add_index :taggings, :tag_id
23
+ add_index :taggings, [:taggable_id, :taggable_type, :context]
24
+ end
25
+
26
+ def self.down
27
+ drop_table :taggings
28
+ drop_table :tags
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ class AddMissingUniqueIndices < ActiveRecord::Migration
2
+
3
+ def self.up
4
+ add_index :tags, :name, unique: true
5
+
6
+ remove_index :taggings, :tag_id
7
+ remove_index :taggings, [:taggable_id, :taggable_type, :context]
8
+ add_index :taggings,
9
+ [:tag_id, :taggable_id, :taggable_type, :context, :tagger_id, :tagger_type],
10
+ unique: true, name: 'taggings_idx'
11
+ end
12
+
13
+ def self.down
14
+ remove_index :tags, :name
15
+
16
+ remove_index :taggings, name: 'tagging_idx'
17
+ add_index :taggings, :tag_id
18
+ add_index :taggings, [:taggable_id, :taggable_type, :context]
19
+ end
20
+
21
+ end
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "rails", "3.2.13"
7
+
8
+ gemspec :path=>"../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "rails", :github => 'rails/rails'
7
+
8
+ gemspec :path=>"../"
@@ -0,0 +1,61 @@
1
+ require "active_record"
2
+ require "active_record/version"
3
+ require "active_support/core_ext/module"
4
+ require "action_view"
5
+ require 'active_support/all'
6
+
7
+ require "digest/sha1"
8
+
9
+ module ActsAsTaggableOn
10
+ mattr_accessor :delimiter
11
+ @@delimiter = ','
12
+
13
+ mattr_accessor :force_lowercase
14
+ @@force_lowercase = false
15
+
16
+ mattr_accessor :force_parameterize
17
+ @@force_parameterize = false
18
+
19
+ mattr_accessor :strict_case_match
20
+ @@strict_case_match = false
21
+
22
+ mattr_accessor :remove_unused_tags
23
+ self.remove_unused_tags = false
24
+
25
+ def self.glue
26
+ delimiter = @@delimiter.kind_of?(Array) ? @@delimiter[0] : @@delimiter
27
+ delimiter.ends_with?(" ") ? delimiter : "#{delimiter} "
28
+ end
29
+
30
+ def self.setup
31
+ yield self
32
+ end
33
+ end
34
+
35
+
36
+ require "acts_as_taggable_on/utils"
37
+
38
+ require "acts_as_taggable_on/taggable"
39
+ require "acts_as_taggable_on/acts_as_taggable_on/compatibility"
40
+ require "acts_as_taggable_on/acts_as_taggable_on/core"
41
+ require "acts_as_taggable_on/acts_as_taggable_on/collection"
42
+ require "acts_as_taggable_on/acts_as_taggable_on/cache"
43
+ require "acts_as_taggable_on/acts_as_taggable_on/ownership"
44
+ require "acts_as_taggable_on/acts_as_taggable_on/related"
45
+ require "acts_as_taggable_on/acts_as_taggable_on/dirty"
46
+
47
+ require "acts_as_taggable_on/tagger"
48
+ require "acts_as_taggable_on/tag"
49
+ require "acts_as_taggable_on/tag_list"
50
+ require "acts_as_taggable_on/tags_helper"
51
+ require "acts_as_taggable_on/tagging"
52
+ require 'acts_as_taggable_on/engine'
53
+
54
+ ActiveSupport.on_load(:active_record) do
55
+ extend ActsAsTaggableOn::Compatibility
56
+ extend ActsAsTaggableOn::Taggable
57
+ include ActsAsTaggableOn::Tagger
58
+ end
59
+ ActiveSupport.on_load(:action_view) do
60
+ include ActsAsTaggableOn::TagsHelper
61
+ end
@@ -0,0 +1,82 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Cache
3
+ def self.included(base)
4
+ # When included, conditionally adds tag caching methods when the model
5
+ # has any "cached_#{tag_type}_list" column
6
+ base.instance_eval do
7
+ # @private
8
+ def _has_acts_as_taggable_on_cache_columns?(db_columns)
9
+ db_column_names = db_columns.map(&:name)
10
+ tag_types.any? {|context|
11
+ db_column_names.include?("cached_#{context.to_s.singularize}_list")
12
+ }
13
+ end
14
+
15
+ # @private
16
+ def _add_acts_as_taggable_on_caching_methods
17
+ send :include, ActsAsTaggableOn::Taggable::Cache::InstanceMethods
18
+ extend ActsAsTaggableOn::Taggable::Cache::ClassMethods
19
+
20
+ before_save :save_cached_tag_list
21
+
22
+ initialize_acts_as_taggable_on_cache
23
+ end
24
+
25
+ # ActiveRecord::Base.columns makes a database connection and caches the calculated
26
+ # columns hash for the record as @columns. Since we don't want to add caching
27
+ # methods until we confirm the presence of a caching column, and we don't
28
+ # want to force opening a database connection when the class is loaded,
29
+ # here we intercept and cache the call to :columns as @acts_as_taggable_on_columns
30
+ # to mimic the underlying behavior. While processing this first call to columns,
31
+ # we do the caching column check and dynamically add the class and instance methods
32
+ def columns
33
+ @acts_as_taggable_on_columns ||= begin
34
+ db_columns = super
35
+ if _has_acts_as_taggable_on_cache_columns?(db_columns)
36
+ _add_acts_as_taggable_on_caching_methods
37
+ end
38
+ db_columns
39
+ end
40
+ end
41
+
42
+ end
43
+ end
44
+
45
+ module ClassMethods
46
+ def initialize_acts_as_taggable_on_cache
47
+ tag_types.map(&:to_s).each do |tag_type|
48
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
49
+ def self.caching_#{tag_type.singularize}_list?
50
+ caching_tag_list_on?("#{tag_type}")
51
+ end
52
+ RUBY
53
+ end
54
+ end
55
+
56
+ def acts_as_taggable_on(*args)
57
+ super(*args)
58
+ initialize_acts_as_taggable_on_cache
59
+ end
60
+
61
+ def caching_tag_list_on?(context)
62
+ column_names.include?("cached_#{context.to_s.singularize}_list")
63
+ end
64
+ end
65
+
66
+ module InstanceMethods
67
+ def save_cached_tag_list
68
+ tag_types.map(&:to_s).each do |tag_type|
69
+ if self.class.send("caching_#{tag_type.singularize}_list?")
70
+ if tag_list_cache_set_on(tag_type)
71
+ list = tag_list_cache_on(tag_type).to_a.flatten.compact.join(', ')
72
+ self["cached_#{tag_type.singularize}_list"] = list
73
+ end
74
+ end
75
+ end
76
+
77
+ true
78
+ end
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,187 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Collection
3
+ def self.included(base)
4
+ base.send :include, ActsAsTaggableOn::Taggable::Collection::InstanceMethods
5
+ base.extend ActsAsTaggableOn::Taggable::Collection::ClassMethods
6
+ base.initialize_acts_as_taggable_on_collection
7
+ end
8
+
9
+ module ClassMethods
10
+ def initialize_acts_as_taggable_on_collection
11
+ tag_types.map(&:to_s).each do |tag_type|
12
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ def self.#{tag_type.singularize}_counts(options={})
14
+ tag_counts_on('#{tag_type}', options)
15
+ end
16
+
17
+ def #{tag_type.singularize}_counts(options = {})
18
+ tag_counts_on('#{tag_type}', options)
19
+ end
20
+
21
+ def top_#{tag_type}(limit = 10)
22
+ tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
23
+ end
24
+
25
+ def self.top_#{tag_type}(limit = 10)
26
+ tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
27
+ end
28
+ RUBY
29
+ end
30
+ end
31
+
32
+ def acts_as_taggable_on(*args)
33
+ super(*args)
34
+ initialize_acts_as_taggable_on_collection
35
+ end
36
+
37
+ def tag_counts_on(context, options = {})
38
+ all_tag_counts(options.merge({:on => context.to_s}))
39
+ end
40
+
41
+ def tags_on(context, options = {})
42
+ all_tags(options.merge({:on => context.to_s}))
43
+ end
44
+
45
+ ##
46
+ # Calculate the tag names.
47
+ # To be used when you don't need tag counts and want to avoid the taggable joins.
48
+ #
49
+ # @param [Hash] options Options:
50
+ # * :start_at - Restrict the tags to those created after a certain time
51
+ # * :end_at - Restrict the tags to those created before a certain time
52
+ # * :conditions - A piece of SQL conditions to add to the query. Note we don't join the taggable objects for performance reasons.
53
+ # * :limit - The maximum number of tags to return
54
+ # * :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
55
+ # * :on - Scope the find to only include a certain context
56
+ def all_tags(options = {})
57
+ options.assert_valid_keys :start_at, :end_at, :conditions, :order, :limit, :on
58
+
59
+ ## Generate conditions:
60
+ options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
61
+
62
+ start_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
63
+ end_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
64
+
65
+ taggable_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.taggable_type = ?", base_class.name])
66
+ taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on]
67
+
68
+ tagging_conditions = [
69
+ taggable_conditions,
70
+ start_at_conditions,
71
+ end_at_conditions
72
+ ].compact.reverse
73
+
74
+ tag_conditions = [
75
+ options[:conditions]
76
+ ].compact.reverse
77
+
78
+ ## Generate scope:
79
+ tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id")
80
+ tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
81
+
82
+ # Joins and conditions
83
+ tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) }
84
+ tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) }
85
+
86
+ group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id"
87
+
88
+ # Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
89
+ scoped_select = "#{table_name}.#{primary_key}"
90
+ tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{safe_to_sql(select(scoped_select))})").group(group_columns)
91
+
92
+ tag_scope = tag_scope.joins("JOIN (#{safe_to_sql(tagging_scope)}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id")
93
+ tag_scope
94
+ end
95
+
96
+ ##
97
+ # Calculate the tag counts for all tags.
98
+ #
99
+ # @param [Hash] options Options:
100
+ # * :start_at - Restrict the tags to those created after a certain time
101
+ # * :end_at - Restrict the tags to those created before a certain time
102
+ # * :conditions - A piece of SQL conditions to add to the query
103
+ # * :limit - The maximum number of tags to return
104
+ # * :order - A piece of SQL to order by. Eg 'tags.count desc' or 'taggings.created_at desc'
105
+ # * :at_least - Exclude tags with a frequency less than the given value
106
+ # * :at_most - Exclude tags with a frequency greater than the given value
107
+ # * :on - Scope the find to only include a certain context
108
+ def all_tag_counts(options = {})
109
+ options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on, :id
110
+
111
+ scope = {}
112
+
113
+ ## Generate conditions:
114
+ options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
115
+
116
+ start_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
117
+ end_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
118
+
119
+ taggable_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.taggable_type = ?", base_class.name])
120
+ taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = ?", options[:id]]) if options[:id]
121
+ taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on]
122
+
123
+ tagging_conditions = [
124
+ taggable_conditions,
125
+ scope[:conditions],
126
+ start_at_conditions,
127
+ end_at_conditions
128
+ ].compact.reverse
129
+
130
+ tag_conditions = [
131
+ options[:conditions]
132
+ ].compact.reverse
133
+
134
+ ## Generate joins:
135
+ taggable_join = "INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id"
136
+ taggable_join << " AND #{table_name}.#{inheritance_column} = '#{name}'" unless descends_from_active_record? # Current model is STI descendant, so add type checking to the join condition
137
+
138
+ tagging_joins = [
139
+ taggable_join,
140
+ scope[:joins]
141
+ ].compact
142
+
143
+ tag_joins = [
144
+ ].compact
145
+
146
+ ## Generate scope:
147
+ tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id, COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) AS tags_count")
148
+ tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*, #{ActsAsTaggableOn::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
149
+
150
+ # Joins and conditions
151
+ tagging_joins.each { |join| tagging_scope = tagging_scope.joins(join) }
152
+ tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) }
153
+
154
+ tag_joins.each { |join| tag_scope = tag_scope.joins(join) }
155
+ tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) }
156
+
157
+ # GROUP BY and HAVING clauses:
158
+ at_least = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) >= ?", options.delete(:at_least)]) if options[:at_least]
159
+ at_most = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) <= ?", options.delete(:at_most)]) if options[:at_most]
160
+ having = ["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) > 0", at_least, at_most].compact.join(' AND ')
161
+
162
+ group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id"
163
+
164
+ unless options[:id]
165
+ # Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
166
+ scoped_select = "#{table_name}.#{primary_key}"
167
+ tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{safe_to_sql(select(scoped_select))})")
168
+ end
169
+
170
+ tagging_scope = tagging_scope.group(group_columns).having(having)
171
+
172
+ tag_scope = tag_scope.joins("JOIN (#{safe_to_sql(tagging_scope)}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id")
173
+ tag_scope
174
+ end
175
+
176
+ def safe_to_sql(relation)
177
+ connection.respond_to?(:unprepared_statement) ? connection.unprepared_statement{relation.to_sql} : relation.to_sql
178
+ end
179
+ end
180
+
181
+ module InstanceMethods
182
+ def tag_counts_on(context, options={})
183
+ self.class.tag_counts_on(context, options.merge(:id => id))
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,34 @@
1
+ module ActsAsTaggableOn::Compatibility
2
+ def has_many_with_compatibility(name, options = {}, &extention)
3
+ if ActiveRecord::VERSION::MAJOR >= 4
4
+ scope, opts = build_scope_and_options(options)
5
+ has_many(name, scope, opts, &extention)
6
+ else
7
+ has_many(name, options, &extention)
8
+ end
9
+ end
10
+
11
+ def build_scope_and_options(opts)
12
+ scope_opts, opts = parse_options(opts)
13
+
14
+ unless scope_opts.empty?
15
+ scope = lambda do
16
+ scope_opts.inject(self) { |result, hash| result.send *hash }
17
+ end
18
+ end
19
+
20
+ [defined?(scope) ? scope : nil, opts]
21
+ end
22
+
23
+ def parse_options(opts)
24
+ scope_opts = {}
25
+ [:order, :having, :select, :group, :limit, :offset, :readonly].each do |o|
26
+ scope_opts[o] = opts.delete o if opts[o]
27
+ end
28
+ scope_opts[:where] = opts.delete :conditions if opts[:conditions]
29
+ scope_opts[:joins] = opts.delete :include if opts [:include]
30
+ scope_opts[:distinct] = opts.delete :uniq if opts[:uniq]
31
+
32
+ [scope_opts, opts]
33
+ end
34
+ end
@@ -0,0 +1,394 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Core
3
+ def self.included(base)
4
+ base.send :include, ActsAsTaggableOn::Taggable::Core::InstanceMethods
5
+ base.extend ActsAsTaggableOn::Taggable::Core::ClassMethods
6
+
7
+ base.class_eval do
8
+ attr_writer :custom_contexts
9
+ after_save :save_tags
10
+ end
11
+
12
+ base.initialize_acts_as_taggable_on_core
13
+ end
14
+
15
+ module ClassMethods
16
+
17
+ def initialize_acts_as_taggable_on_core
18
+ include taggable_mixin
19
+ tag_types.map(&:to_s).each do |tags_type|
20
+ tag_type = tags_type.to_s.singularize
21
+ context_taggings = "#{tag_type}_taggings".to_sym
22
+ context_tags = tags_type.to_sym
23
+ taggings_order = (preserve_tag_order? ? "#{ActsAsTaggableOn::Tagging.table_name}.id" : [])
24
+
25
+ class_eval do
26
+ # when preserving tag order, include order option so that for a 'tags' context
27
+ # the associations tag_taggings & tags are always returned in created order
28
+ has_many_with_compatibility context_taggings, :as => :taggable,
29
+ :dependent => :destroy,
30
+ :class_name => "ActsAsTaggableOn::Tagging",
31
+ :order => taggings_order,
32
+ :conditions => ["#{ActsAsTaggableOn::Tagging.table_name}.context = (?)", tags_type],
33
+ :include => :tag
34
+
35
+ has_many_with_compatibility context_tags, :through => context_taggings,
36
+ :source => :tag,
37
+ :class_name => "ActsAsTaggableOn::Tag",
38
+ :order => taggings_order
39
+
40
+ end
41
+
42
+ taggable_mixin.class_eval <<-RUBY, __FILE__, __LINE__ + 1
43
+ def #{tag_type}_list
44
+ tag_list_on('#{tags_type}')
45
+ end
46
+
47
+ def #{tag_type}_list=(new_tags)
48
+ set_tag_list_on('#{tags_type}', new_tags)
49
+ end
50
+
51
+ def all_#{tags_type}_list
52
+ all_tags_list_on('#{tags_type}')
53
+ end
54
+ RUBY
55
+ end
56
+ end
57
+
58
+ def taggable_on(preserve_tag_order, *tag_types)
59
+ super(preserve_tag_order, *tag_types)
60
+ initialize_acts_as_taggable_on_core
61
+ end
62
+
63
+ # all column names are necessary for PostgreSQL group clause
64
+ def grouped_column_names_for(object)
65
+ object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
66
+ end
67
+
68
+ ##
69
+ # Return a scope of objects that are tagged with the specified tags.
70
+ #
71
+ # @param tags The tags that we want to query for
72
+ # @param [Hash] options A hash of options to alter you query:
73
+ # * <tt>:exclude</tt> - if set to true, return objects that are *NOT* tagged with the specified tags
74
+ # * <tt>:any</tt> - if set to true, return objects that are tagged with *ANY* of the specified tags
75
+ # * <tt>:match_all</tt> - if set to true, return objects that are *ONLY* tagged with the specified tags
76
+ # * <tt>:owned_by</tt> - return objects that are *ONLY* owned by the owner
77
+ #
78
+ # Example:
79
+ # User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
80
+ # User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
81
+ # User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
82
+ # User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
83
+ # User.tagged_with("awesome", "cool", :owned_by => foo ) # Users that are tagged with just awesome and cool by 'foo'
84
+ def tagged_with(tags, options = {})
85
+ tag_list = ActsAsTaggableOn::TagList.from(tags)
86
+ empty_result = where("1 = 0")
87
+
88
+ return empty_result if tag_list.empty?
89
+
90
+ joins = []
91
+ conditions = []
92
+ having = []
93
+ select_clause = []
94
+
95
+ context = options.delete(:on)
96
+ owned_by = options.delete(:owned_by)
97
+ alias_base_name = undecorated_table_name.gsub('.','_')
98
+ quote = ActsAsTaggableOn::Tag.using_postgresql? ? '"' : ''
99
+
100
+ if options.delete(:exclude)
101
+ if options.delete(:wild)
102
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ? ESCAPE '!'", "%#{escape_like(t)}%"]) }.join(" OR ")
103
+ else
104
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ?", t]) }.join(" OR ")
105
+ end
106
+
107
+ conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{ActsAsTaggableOn::Tagging.table_name}.taggable_id FROM #{ActsAsTaggableOn::Tagging.table_name} JOIN #{ActsAsTaggableOn::Tag.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
108
+
109
+ if owned_by
110
+ joins << "JOIN #{ActsAsTaggableOn::Tagging.table_name}" +
111
+ " ON #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
112
+ " AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)}" +
113
+ " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = #{quote_value(owned_by.id)}" +
114
+ " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = #{quote_value(owned_by.class.base_class.to_s)}"
115
+ end
116
+
117
+ elsif options.delete(:any)
118
+ # get tags, drop out if nothing returned (we need at least one)
119
+ tags = if options.delete(:wild)
120
+ ActsAsTaggableOn::Tag.named_like_any(tag_list)
121
+ else
122
+ ActsAsTaggableOn::Tag.named_any(tag_list)
123
+ end
124
+
125
+ return empty_result unless tags.length > 0
126
+
127
+ # setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123
128
+ # avoid ambiguous column name
129
+ taggings_context = context ? "_#{context}" : ''
130
+
131
+ taggings_alias = adjust_taggings_alias(
132
+ "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{sha_prefix(tags.map(&:name).join('_'))}"
133
+ )
134
+
135
+ tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
136
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
137
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
138
+ tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
139
+
140
+ # don't need to sanitize sql, map all ids and join with OR logic
141
+ conditions << tags.map { |t| "#{taggings_alias}.tag_id = #{quote_value(t.id)}" }.join(" OR ")
142
+ select_clause = "DISTINCT #{table_name}.*" unless context and tag_types.one?
143
+
144
+ if owned_by
145
+ tagging_join << " AND " +
146
+ sanitize_sql([
147
+ "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
148
+ owned_by.id,
149
+ owned_by.class.base_class.to_s
150
+ ])
151
+ end
152
+
153
+ joins << tagging_join
154
+ else
155
+ tags = ActsAsTaggableOn::Tag.named_any(tag_list)
156
+
157
+ return empty_result unless tags.length == tag_list.length
158
+
159
+ tags.each do |tag|
160
+ taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{sha_prefix(tag.name)}")
161
+ tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
162
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
163
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
164
+ " AND #{taggings_alias}.tag_id = #{quote_value(tag.id)}"
165
+
166
+ tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
167
+
168
+ if owned_by
169
+ tagging_join << " AND " +
170
+ sanitize_sql([
171
+ "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
172
+ owned_by.id,
173
+ owned_by.class.base_class.to_s
174
+ ])
175
+ end
176
+
177
+ joins << tagging_join
178
+ end
179
+ end
180
+
181
+ taggings_alias, tags_alias = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group"
182
+
183
+ if options.delete(:match_all)
184
+ joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
185
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
186
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
187
+
188
+
189
+ group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
190
+ group = group_columns
191
+ having = "COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
192
+ end
193
+
194
+ select(select_clause) \
195
+ .joins(joins.join(" ")) \
196
+ .where(conditions.join(" AND ")) \
197
+ .group(group) \
198
+ .having(having) \
199
+ .order(options[:order]) \
200
+ .readonly(false)
201
+ end
202
+
203
+ def is_taggable?
204
+ true
205
+ end
206
+
207
+ def adjust_taggings_alias(taggings_alias)
208
+ if taggings_alias.size > 75
209
+ taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias)
210
+ end
211
+ taggings_alias
212
+ end
213
+
214
+ def taggable_mixin
215
+ @taggable_mixin ||= Module.new
216
+ end
217
+ end
218
+
219
+ module InstanceMethods
220
+ # all column names are necessary for PostgreSQL group clause
221
+ def grouped_column_names_for(object)
222
+ self.class.grouped_column_names_for(object)
223
+ end
224
+
225
+ def custom_contexts
226
+ @custom_contexts ||= []
227
+ end
228
+
229
+ def is_taggable?
230
+ self.class.is_taggable?
231
+ end
232
+
233
+ def add_custom_context(value)
234
+ custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) or self.class.tag_types.map(&:to_s).include?(value.to_s)
235
+ end
236
+
237
+ def cached_tag_list_on(context)
238
+ self["cached_#{context.to_s.singularize}_list"]
239
+ end
240
+
241
+ def tag_list_cache_set_on(context)
242
+ variable_name = "@#{context.to_s.singularize}_list"
243
+ instance_variable_defined?(variable_name) && !instance_variable_get(variable_name).nil?
244
+ end
245
+
246
+ def tag_list_cache_on(context)
247
+ variable_name = "@#{context.to_s.singularize}_list"
248
+ if instance_variable_get(variable_name)
249
+ instance_variable_get(variable_name)
250
+ elsif cached_tag_list_on(context) && self.class.caching_tag_list_on?(context)
251
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(cached_tag_list_on(context)))
252
+ else
253
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(tags_on(context).map(&:name)))
254
+ end
255
+ end
256
+
257
+ def tag_list_on(context)
258
+ add_custom_context(context)
259
+ tag_list_cache_on(context)
260
+ end
261
+
262
+ def all_tags_list_on(context)
263
+ variable_name = "@all_#{context.to_s.singularize}_list"
264
+ return instance_variable_get(variable_name) if instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
265
+
266
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(all_tags_on(context).map(&:name)).freeze)
267
+ end
268
+
269
+ ##
270
+ # Returns all tags of a given context
271
+ def all_tags_on(context)
272
+ tag_table_name = ActsAsTaggableOn::Tag.table_name
273
+ tagging_table_name = ActsAsTaggableOn::Tagging.table_name
274
+
275
+ opts = ["#{tagging_table_name}.context = ?", context.to_s]
276
+ scope = base_tags.where(opts)
277
+
278
+ if ActsAsTaggableOn::Tag.using_postgresql?
279
+ group_columns = grouped_column_names_for(ActsAsTaggableOn::Tag)
280
+ scope.order("max(#{tagging_table_name}.created_at)").group(group_columns)
281
+ else
282
+ scope.group("#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}")
283
+ end.to_a
284
+ end
285
+
286
+ ##
287
+ # Returns all tags that are not owned of a given context
288
+ def tags_on(context)
289
+ scope = base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s])
290
+ # when preserving tag order, return tags in created order
291
+ # if we added the order to the association this would always apply
292
+ scope = scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") if self.class.preserve_tag_order?
293
+ scope
294
+ end
295
+
296
+ def set_tag_list_on(context, new_list)
297
+ add_custom_context(context)
298
+
299
+ variable_name = "@#{context.to_s.singularize}_list"
300
+ process_dirty_object(context, new_list) unless custom_contexts.include?(context.to_s)
301
+
302
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(new_list))
303
+ end
304
+
305
+ def tagging_contexts
306
+ custom_contexts + self.class.tag_types.map(&:to_s)
307
+ end
308
+
309
+ def process_dirty_object(context,new_list)
310
+ value = new_list.is_a?(Array) ? new_list.join(', ') : new_list
311
+ attrib = "#{context.to_s.singularize}_list"
312
+
313
+ if changed_attributes.include?(attrib)
314
+ # The attribute already has an unsaved change.
315
+ old = changed_attributes[attrib]
316
+ changed_attributes.delete(attrib) if (old.to_s == value.to_s)
317
+ else
318
+ old = tag_list_on(context).to_s
319
+ changed_attributes[attrib] = old if (old.to_s != value.to_s)
320
+ end
321
+ end
322
+
323
+ def reload(*args)
324
+ self.class.tag_types.each do |context|
325
+ instance_variable_set("@#{context.to_s.singularize}_list", nil)
326
+ instance_variable_set("@all_#{context.to_s.singularize}_list", nil)
327
+ end
328
+
329
+ super(*args)
330
+ end
331
+
332
+ ##
333
+ # Find existing tags or create non-existing tags
334
+ def load_tags(tag_list)
335
+ ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list)
336
+ end
337
+
338
+ def save_tags
339
+ tagging_contexts.each do |context|
340
+ next unless tag_list_cache_set_on(context)
341
+ # List of currently assigned tag names
342
+ tag_list = tag_list_cache_on(context).uniq
343
+
344
+ # Find existing tags or create non-existing tags:
345
+ tags = load_tags(tag_list)
346
+
347
+ # Tag objects for currently assigned tags
348
+ current_tags = tags_on(context)
349
+
350
+ # Tag maintenance based on whether preserving the created order of tags
351
+ if self.class.preserve_tag_order?
352
+ old_tags, new_tags = current_tags - tags, tags - current_tags
353
+
354
+ shared_tags = current_tags & tags
355
+
356
+ if shared_tags.any? && tags[0...shared_tags.size] != shared_tags
357
+ index = shared_tags.each_with_index { |_, i| break i unless shared_tags[i] == tags[i] }
358
+
359
+ # Update arrays of tag objects
360
+ old_tags |= current_tags[index...current_tags.size]
361
+ new_tags |= current_tags[index...current_tags.size] & shared_tags
362
+
363
+ # Order the array of tag objects to match the tag list
364
+ new_tags = tags.map do |t|
365
+ new_tags.find { |n| n.name.downcase == t.name.downcase }
366
+ end.compact
367
+ end
368
+ else
369
+ # Delete discarded tags and create new tags
370
+ old_tags = current_tags - tags
371
+ new_tags = tags - current_tags
372
+ end
373
+
374
+ # Find taggings to remove:
375
+ if old_tags.present?
376
+ old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil, :context => context.to_s, :tag_id => old_tags)
377
+ end
378
+
379
+ # Destroy old taggings:
380
+ if old_taggings.present?
381
+ ActsAsTaggableOn::Tagging.destroy_all "#{ActsAsTaggableOn::Tagging.primary_key}".to_sym => old_taggings.map(&:id)
382
+ end
383
+
384
+ # Create new taggings:
385
+ new_tags.each do |tag|
386
+ taggings.create!(:tag_id => tag.id, :context => context.to_s, :taggable => self)
387
+ end
388
+ end
389
+
390
+ true
391
+ end
392
+ end
393
+ end
394
+ end