acts_as_taggable_on 3.0.0.rc1

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 (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