acts-as-taggable-on 2.0.6 → 2.4.0

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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +9 -0
  5. data/CHANGELOG.md +35 -0
  6. data/Gemfile +2 -9
  7. data/Guardfile +5 -0
  8. data/{MIT-LICENSE → MIT-LICENSE.md} +1 -1
  9. data/README.md +297 -0
  10. data/Rakefile +9 -55
  11. data/UPGRADING +14 -0
  12. data/acts-as-taggable-on.gemspec +32 -0
  13. data/lib/acts-as-taggable-on/version.rb +4 -0
  14. data/lib/acts-as-taggable-on.rb +37 -4
  15. data/lib/acts_as_taggable_on/acts_as_taggable_on/cache.rb +6 -6
  16. data/lib/acts_as_taggable_on/acts_as_taggable_on/collection.rb +99 -45
  17. data/lib/acts_as_taggable_on/acts_as_taggable_on/core.rb +162 -45
  18. data/lib/acts_as_taggable_on/acts_as_taggable_on/dirty.rb +37 -0
  19. data/lib/acts_as_taggable_on/acts_as_taggable_on/ownership.rb +40 -15
  20. data/lib/acts_as_taggable_on/acts_as_taggable_on/related.rb +28 -18
  21. data/lib/acts_as_taggable_on/tag.rb +41 -16
  22. data/lib/acts_as_taggable_on/tag_list.rb +19 -14
  23. data/lib/acts_as_taggable_on/taggable.rb +102 -0
  24. data/lib/acts_as_taggable_on/{acts_as_tagger.rb → tagger.rb} +3 -3
  25. data/lib/acts_as_taggable_on/tagging.rb +12 -2
  26. data/lib/acts_as_taggable_on/tags_helper.rb +2 -2
  27. data/lib/acts_as_taggable_on/utils.rb +34 -0
  28. data/lib/generators/acts_as_taggable_on/migration/migration_generator.rb +9 -2
  29. data/lib/generators/acts_as_taggable_on/migration/templates/active_record/migration.rb +3 -1
  30. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +333 -54
  31. data/spec/acts_as_taggable_on/tag_list_spec.rb +117 -61
  32. data/spec/acts_as_taggable_on/tag_spec.rb +111 -14
  33. data/spec/acts_as_taggable_on/taggable_spec.rb +330 -34
  34. data/spec/acts_as_taggable_on/tagger_spec.rb +62 -15
  35. data/spec/acts_as_taggable_on/tagging_spec.rb +2 -5
  36. data/spec/acts_as_taggable_on/tags_helper_spec.rb +16 -0
  37. data/spec/acts_as_taggable_on/utils_spec.rb +21 -0
  38. data/spec/database.yml.sample +4 -2
  39. data/spec/generators/acts_as_taggable_on/migration/migration_generator_spec.rb +22 -0
  40. data/spec/models.rb +28 -1
  41. data/spec/schema.rb +18 -0
  42. data/spec/spec_helper.rb +30 -7
  43. data/uninstall.rb +1 -0
  44. metadata +174 -57
  45. data/CHANGELOG +0 -25
  46. data/README.rdoc +0 -221
  47. data/VERSION +0 -1
  48. data/generators/acts_as_taggable_on_migration/acts_as_taggable_on_migration_generator.rb +0 -7
  49. data/generators/acts_as_taggable_on_migration/templates/migration.rb +0 -29
  50. data/lib/acts_as_taggable_on/acts_as_taggable_on.rb +0 -53
  51. data/lib/acts_as_taggable_on/compatibility/Gemfile +0 -8
  52. data/lib/acts_as_taggable_on/compatibility/active_record_backports.rb +0 -17
  53. data/lib/acts_as_taggable_on/compatibility/postgresql.rb +0 -44
  54. data/spec/database.yml +0 -17
@@ -9,7 +9,7 @@ module ActsAsTaggableOn::Taggable
9
9
  module ClassMethods
10
10
  def initialize_acts_as_taggable_on_collection
11
11
  tag_types.map(&:to_s).each do |tag_type|
12
- class_eval %(
12
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
13
13
  def self.#{tag_type.singularize}_counts(options={})
14
14
  tag_counts_on('#{tag_type}', options)
15
15
  end
@@ -25,7 +25,7 @@ module ActsAsTaggableOn::Taggable
25
25
  def self.top_#{tag_type}(limit = 10)
26
26
  tag_counts_on('#{tag_type}', :order => 'count desc', :limit => limit.to_i)
27
27
  end
28
- )
28
+ RUBY
29
29
  end
30
30
  end
31
31
 
@@ -37,6 +37,62 @@ module ActsAsTaggableOn::Taggable
37
37
  def tag_counts_on(context, options = {})
38
38
  all_tag_counts(options.merge({:on => context.to_s}))
39
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(#{select(scoped_select).to_sql})").
91
+ group(group_columns)
92
+
93
+ tag_scope = tag_scope.joins("JOIN (#{tagging_scope.to_sql}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id")
94
+ tag_scope
95
+ end
40
96
 
41
97
  ##
42
98
  # Calculate the tag counts for all tags.
@@ -53,73 +109,71 @@ module ActsAsTaggableOn::Taggable
53
109
  def all_tag_counts(options = {})
54
110
  options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :on, :id
55
111
 
56
- scope = if ActiveRecord::VERSION::MAJOR >= 3
57
- {}
58
- else
59
- scope(:find) || {}
60
- end
112
+ scope = {}
61
113
 
62
114
  ## Generate conditions:
63
115
  options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
64
-
116
+
65
117
  start_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
66
118
  end_at_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
67
-
119
+
68
120
  taggable_conditions = sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.taggable_type = ?", base_class.name])
69
- taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = ?", options.delete(:id)]) if options[:id]
70
-
71
- conditions = [
121
+ taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = ?", options.delete(:id)]) if options[:id]
122
+ taggable_conditions << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on]
123
+
124
+ tagging_conditions = [
72
125
  taggable_conditions,
73
- options[:conditions],
74
126
  scope[:conditions],
75
127
  start_at_conditions,
76
128
  end_at_conditions
77
129
  ].compact.reverse
78
130
 
131
+ tag_conditions = [
132
+ options[:conditions]
133
+ ].compact.reverse
134
+
79
135
  ## Generate joins:
80
- tagging_join = "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tag.table_name}.id = #{ActsAsTaggableOn::Tagging.table_name}.tag_id"
81
- tagging_join << sanitize_sql([" AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", options.delete(:on).to_s]) if options[:on]
82
-
83
136
  taggable_join = "INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{ActsAsTaggableOn::Tagging.table_name}.taggable_id"
84
137
  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
85
138
 
86
- joins = [
87
- tagging_join,
139
+ tagging_joins = [
88
140
  taggable_join,
89
141
  scope[:joins]
90
142
  ].compact
91
143
 
92
- joins = joins.reverse if ActiveRecord::VERSION::MAJOR < 3
93
-
144
+ tag_joins = [
145
+ ].compact
94
146
 
95
147
  ## Generate scope:
96
- scope = ActsAsTaggableOn::Tag.scoped(:select => "#{ActsAsTaggableOn::Tag.table_name}.*, COUNT(*) AS count").order(options[:order]).limit(options[:limit])
97
-
148
+ tagging_scope = ActsAsTaggableOn::Tagging.select("#{ActsAsTaggableOn::Tagging.table_name}.tag_id, COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) AS tags_count")
149
+ tag_scope = ActsAsTaggableOn::Tag.select("#{ActsAsTaggableOn::Tag.table_name}.*, #{ActsAsTaggableOn::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
150
+
98
151
  # Joins and conditions
99
- joins.each { |join| scope = scope.joins(join) }
100
- conditions.each { |condition| scope = scope.where(condition) }
101
-
152
+ tagging_joins.each { |join| tagging_scope = tagging_scope.joins(join) }
153
+ tagging_conditions.each { |condition| tagging_scope = tagging_scope.where(condition) }
154
+
155
+ tag_joins.each { |join| tag_scope = tag_scope.joins(join) }
156
+ tag_conditions.each { |condition| tag_scope = tag_scope.where(condition) }
157
+
102
158
  # GROUP BY and HAVING clauses:
103
- at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
104
- at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
105
- having = [at_least, at_most].compact.join(' AND ')
106
-
107
- if ActiveRecord::VERSION::MAJOR >= 3
108
- # Append the current scope to the scope, because we can't use scope(:find) in RoR 3.0 anymore:
109
- scoped_select = "#{table_name}.#{primary_key}"
110
- scope = scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{select(scoped_select).to_sql})")
111
-
112
- # We have having() in RoR 3.0 so use it:
113
- having = having.blank? ? "COUNT(*) > 0" : "COUNT(*) > 0 AND #{having}"
114
- scope = scope.group(grouped_column_names_for(ActsAsTaggableOn::Tag)).having(having)
115
- else
116
- # Having is not available in 2.3.x:
117
- group_by = "#{grouped_column_names_for(ActsAsTaggableOn::Tag)} HAVING COUNT(*) > 0"
118
- group_by << " AND #{having}" unless having.blank?
119
- scope = scope.group(group_by)
120
- end
121
-
122
- scope
159
+ at_least = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) >= ?", options.delete(:at_least)]) if options[:at_least]
160
+ at_most = sanitize_sql(["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) <= ?", options.delete(:at_most)]) if options[:at_most]
161
+ having = ["COUNT(#{ActsAsTaggableOn::Tagging.table_name}.tag_id) > 0", at_least, at_most].compact.join(' AND ')
162
+
163
+ group_columns = "#{ActsAsTaggableOn::Tagging.table_name}.tag_id"
164
+
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
+ select_query = "#{select(scoped_select).to_sql}"
168
+
169
+ res = ActiveRecord::Base.connection.select_all(select_query).map { |item| item.values }.flatten.join(",")
170
+ res = "NULL" if res.blank?
171
+
172
+ tagging_scope = tagging_scope.where("#{ActsAsTaggableOn::Tagging.table_name}.taggable_id IN(#{res})")
173
+ tagging_scope = tagging_scope.group(group_columns).having(having)
174
+
175
+ tag_scope = tag_scope.joins("JOIN (#{tagging_scope.to_sql}) AS #{ActsAsTaggableOn::Tagging.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.id")
176
+ tag_scope
123
177
  end
124
178
  end
125
179
 
@@ -8,24 +8,36 @@ module ActsAsTaggableOn::Taggable
8
8
  attr_writer :custom_contexts
9
9
  after_save :save_tags
10
10
  end
11
-
11
+
12
12
  base.initialize_acts_as_taggable_on_core
13
13
  end
14
-
14
+
15
15
  module ClassMethods
16
16
  def initialize_acts_as_taggable_on_core
17
+ include taggable_mixin
17
18
  tag_types.map(&:to_s).each do |tags_type|
18
19
  tag_type = tags_type.to_s.singularize
19
20
  context_taggings = "#{tag_type}_taggings".to_sym
20
21
  context_tags = tags_type.to_sym
21
-
22
+ taggings_order = (preserve_tag_order? ? "#{ActsAsTaggableOn::Tagging.table_name}.id" : nil)
23
+
22
24
  class_eval do
23
- has_many context_taggings, :as => :taggable, :dependent => :destroy, :include => :tag, :class_name => "ActsAsTaggableOn::Tagging",
24
- :conditions => ["#{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL AND #{ActsAsTaggableOn::Tagging.table_name}.context = ?", tags_type]
25
- has_many context_tags, :through => context_taggings, :source => :tag, :class_name => "ActsAsTaggableOn::Tag"
25
+ # when preserving tag order, include order option so that for a 'tags' context
26
+ # the associations tag_taggings & tags are always returned in created order
27
+ has_many context_taggings, :as => :taggable,
28
+ :dependent => :destroy,
29
+ :include => :tag,
30
+ :class_name => "ActsAsTaggableOn::Tagging",
31
+ :conditions => ["#{ActsAsTaggableOn::Tagging.table_name}.context = ?", tags_type],
32
+ :order => taggings_order
33
+
34
+ has_many context_tags, :through => context_taggings,
35
+ :source => :tag,
36
+ :class_name => "ActsAsTaggableOn::Tag",
37
+ :order => taggings_order
26
38
  end
27
39
 
28
- class_eval %(
40
+ taggable_mixin.class_eval <<-RUBY, __FILE__, __LINE__ + 1
29
41
  def #{tag_type}_list
30
42
  tag_list_on('#{tags_type}')
31
43
  end
@@ -37,12 +49,12 @@ module ActsAsTaggableOn::Taggable
37
49
  def all_#{tags_type}_list
38
50
  all_tags_list_on('#{tags_type}')
39
51
  end
40
- )
41
- end
52
+ RUBY
53
+ end
42
54
  end
43
-
44
- def acts_as_taggable_on(*args)
45
- super(*args)
55
+
56
+ def taggable_on(preserve_tag_order, *tag_types)
57
+ super(preserve_tag_order, *tag_types)
46
58
  initialize_acts_as_taggable_on_core
47
59
  end
48
60
 
@@ -59,63 +71,110 @@ module ActsAsTaggableOn::Taggable
59
71
  # * <tt>:exclude</tt> - if set to true, return objects that are *NOT* tagged with the specified tags
60
72
  # * <tt>:any</tt> - if set to true, return objects that are tagged with *ANY* of the specified tags
61
73
  # * <tt>:match_all</tt> - if set to true, return objects that are *ONLY* tagged with the specified tags
74
+ # * <tt>:owned_by</tt> - return objects that are *ONLY* owned by the owner
62
75
  #
63
76
  # Example:
64
77
  # User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
65
78
  # User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
66
79
  # User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
67
80
  # User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
81
+ # User.tagged_with("awesome", "cool", :owned_by => foo ) # Users that are tagged with just awesome and cool by 'foo'
68
82
  def tagged_with(tags, options = {})
69
83
  tag_list = ActsAsTaggableOn::TagList.from(tags)
84
+ empty_result = scoped(:conditions => "1 = 0")
70
85
 
71
- return {} if tag_list.empty?
86
+ return empty_result if tag_list.empty?
72
87
 
73
88
  joins = []
74
89
  conditions = []
75
90
 
76
91
  context = options.delete(:on)
92
+ owned_by = options.delete(:owned_by)
93
+ alias_base_name = undecorated_table_name.gsub('.','_')
94
+ quote = ActsAsTaggableOn::Tag.using_postgresql? ? '"' : ''
77
95
 
78
96
  if options.delete(:exclude)
79
- tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
80
- 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}.id AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
97
+ if options.delete(:wild)
98
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ? ESCAPE '!'", "%#{escape_like(t)}%"]) }.join(" OR ")
99
+ else
100
+ tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{like_operator} ?", t]) }.join(" OR ")
101
+ end
102
+
103
+ 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)})"
81
104
 
82
105
  elsif options.delete(:any)
83
- tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name LIKE ?", t]) }.join(" OR ")
84
- conditions << "#{table_name}.#{primary_key} 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}.id AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})"
106
+ # get tags, drop out if nothing returned (we need at least one)
107
+ if options.delete(:wild)
108
+ tags = ActsAsTaggableOn::Tag.named_like_any(tag_list)
109
+ else
110
+ tags = ActsAsTaggableOn::Tag.named_any(tag_list)
111
+ end
112
+
113
+ return scoped(:conditions => "1 = 0") unless tags.length > 0
114
+
115
+ # setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123
116
+ # avoid ambiguous column name
117
+ taggings_context = context ? "_#{context}" : ''
118
+
119
+ taggings_alias = adjust_taggings_alias(
120
+ "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{sha_prefix(tags.map(&:name).join('_'))}"
121
+ )
122
+
123
+ tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
124
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
125
+ " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
126
+ tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
127
+
128
+ # don't need to sanitize sql, map all ids and join with OR logic
129
+ conditions << tags.map { |t| "#{taggings_alias}.tag_id = #{t.id}" }.join(" OR ")
130
+ select_clause = "DISTINCT #{table_name}.*" unless context and tag_types.one?
131
+
132
+ joins << tagging_join
85
133
 
86
134
  else
87
135
  tags = ActsAsTaggableOn::Tag.named_any(tag_list)
88
- return scoped(:conditions => "1 = 0") unless tags.length == tag_list.length
136
+ return empty_result unless tags.length == tag_list.length
89
137
 
90
138
  tags.each do |tag|
91
- safe_tag = tag.name.gsub(/[^a-zA-Z0-9]/, '')
92
- prefix = "#{safe_tag}_#{rand(1024)}"
93
139
 
94
- taggings_alias = "#{table_name}_taggings_#{prefix}"
140
+ taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{sha_prefix(tag.name)}")
95
141
 
96
142
  tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
97
- " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
143
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
98
144
  " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
99
145
  " AND #{taggings_alias}.tag_id = #{tag.id}"
100
146
  tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context
101
147
 
148
+ if owned_by
149
+ tagging_join << " AND " +
150
+ sanitize_sql([
151
+ "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?",
152
+ owned_by.id,
153
+ owned_by.class.base_class.to_s
154
+ ])
155
+ end
156
+
102
157
  joins << tagging_join
103
158
  end
104
159
  end
105
160
 
106
- taggings_alias, tags_alias = "#{table_name}_taggings_group", "#{table_name}_tags_group"
161
+ taggings_alias, tags_alias = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group"
107
162
 
108
163
  if options.delete(:match_all)
109
164
  joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" +
110
- " ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
165
+ " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" +
111
166
  " AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
112
167
 
113
- group = "#{grouped_column_names_for(self)} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
114
- end
115
168
 
169
+ group_columns = ActsAsTaggableOn::Tag.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
170
+ group = group_columns
171
+ having = "COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
172
+ end
116
173
 
117
- scoped(:joins => joins.join(" "),
174
+ scoped(:select => select_clause,
175
+ :joins => joins.join(" "),
118
176
  :group => group,
177
+ :having => having,
119
178
  :conditions => conditions.join(" AND "),
120
179
  :order => options[:order],
121
180
  :readonly => false)
@@ -124,8 +183,19 @@ module ActsAsTaggableOn::Taggable
124
183
  def is_taggable?
125
184
  true
126
185
  end
127
- end
128
-
186
+
187
+ def adjust_taggings_alias(taggings_alias)
188
+ if taggings_alias.size > 75
189
+ taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias)
190
+ end
191
+ taggings_alias
192
+ end
193
+
194
+ def taggable_mixin
195
+ @taggable_mixin ||= Module.new
196
+ end
197
+ end
198
+
129
199
  module InstanceMethods
130
200
  # all column names are necessary for PostgreSQL group clause
131
201
  def grouped_column_names_for(object)
@@ -150,12 +220,13 @@ module ActsAsTaggableOn::Taggable
150
220
 
151
221
  def tag_list_cache_set_on(context)
152
222
  variable_name = "@#{context.to_s.singularize}_list"
153
- !instance_variable_get(variable_name).nil?
223
+ instance_variable_defined?(variable_name) && !instance_variable_get(variable_name).nil?
154
224
  end
155
225
 
156
226
  def tag_list_cache_on(context)
157
227
  variable_name = "@#{context.to_s.singularize}_list"
158
- instance_variable_get(variable_name) || instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(tags_on(context).map(&:name)))
228
+ return instance_variable_get(variable_name) if instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
229
+ instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(tags_on(context).map(&:name)))
159
230
  end
160
231
 
161
232
  def tag_list_on(context)
@@ -165,7 +236,7 @@ module ActsAsTaggableOn::Taggable
165
236
 
166
237
  def all_tags_list_on(context)
167
238
  variable_name = "@all_#{context.to_s.singularize}_list"
168
- return instance_variable_get(variable_name) if instance_variable_get(variable_name)
239
+ return instance_variable_get(variable_name) if instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
169
240
 
170
241
  instance_variable_set(variable_name, ActsAsTaggableOn::TagList.new(all_tags_on(context).map(&:name)).freeze)
171
242
  end
@@ -176,20 +247,35 @@ module ActsAsTaggableOn::Taggable
176
247
  tag_table_name = ActsAsTaggableOn::Tag.table_name
177
248
  tagging_table_name = ActsAsTaggableOn::Tagging.table_name
178
249
 
179
- opts = ["#{tagging_table_name}.context = ?", context.to_s]
180
- base_tags.where(opts).order("max(#{tagging_table_name}.created_at)").group("#{tag_table_name}.id, #{tag_table_name}.name").all
250
+ opts = ["#{tagging_table_name}.context = ?", context.to_s]
251
+ scope = base_tags.where(opts)
252
+
253
+ if ActsAsTaggableOn::Tag.using_postgresql?
254
+ group_columns = grouped_column_names_for(ActsAsTaggableOn::Tag)
255
+ scope = scope.order("max(#{tagging_table_name}.created_at)").group(group_columns)
256
+ else
257
+ scope = scope.group("#{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key}")
258
+ end
259
+
260
+ scope.all
181
261
  end
182
262
 
183
263
  ##
184
264
  # Returns all tags that are not owned of a given context
185
265
  def tags_on(context)
186
- base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s]).all
266
+ scope = base_tags.where(["#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id IS NULL", context.to_s])
267
+ # when preserving tag order, return tags in created order
268
+ # if we added the order to the association this would always apply
269
+ scope = scope.order("#{ActsAsTaggableOn::Tagging.table_name}.id") if self.class.preserve_tag_order?
270
+ scope.all
187
271
  end
188
272
 
189
273
  def set_tag_list_on(context, new_list)
190
274
  add_custom_context(context)
191
275
 
192
276
  variable_name = "@#{context.to_s.singularize}_list"
277
+ process_dirty_object(context, new_list) unless custom_contexts.include?(context.to_s)
278
+
193
279
  instance_variable_set(variable_name, ActsAsTaggableOn::TagList.from(new_list))
194
280
  end
195
281
 
@@ -197,12 +283,26 @@ module ActsAsTaggableOn::Taggable
197
283
  custom_contexts + self.class.tag_types.map(&:to_s)
198
284
  end
199
285
 
286
+ def process_dirty_object(context,new_list)
287
+ value = new_list.is_a?(Array) ? new_list.join(', ') : new_list
288
+ attrib = "#{context.to_s.singularize}_list"
289
+
290
+ if changed_attributes.include?(attrib)
291
+ # The attribute already has an unsaved change.
292
+ old = changed_attributes[attrib]
293
+ changed_attributes.delete(attrib) if (old.to_s == value.to_s)
294
+ else
295
+ old = tag_list_on(context).to_s
296
+ changed_attributes[attrib] = old if (old.to_s != value.to_s)
297
+ end
298
+ end
299
+
200
300
  def reload(*args)
201
301
  self.class.tag_types.each do |context|
202
302
  instance_variable_set("@#{context.to_s.singularize}_list", nil)
203
303
  instance_variable_set("@all_#{context.to_s.singularize}_list", nil)
204
304
  end
205
-
305
+
206
306
  super(*args)
207
307
  end
208
308
 
@@ -210,22 +310,39 @@ module ActsAsTaggableOn::Taggable
210
310
  tagging_contexts.each do |context|
211
311
  next unless tag_list_cache_set_on(context)
212
312
 
313
+ # List of currently assigned tag names
213
314
  tag_list = tag_list_cache_on(context).uniq
214
315
 
215
316
  # Find existing tags or create non-existing tags:
216
- tag_list = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list)
317
+ tags = ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(tag_list)
217
318
 
319
+ # Tag objects for currently assigned tags
218
320
  current_tags = tags_on(context)
219
- old_tags = current_tags - tag_list
220
- new_tags = tag_list - current_tags
221
-
321
+
322
+ # Tag maintenance based on whether preserving the created order of tags
323
+ if self.class.preserve_tag_order?
324
+ # First off order the array of tag objects to match the tag list
325
+ # rather than existing tags followed by new tags
326
+ tags = tag_list.map{|l| tags.detect{|t| t.name.downcase == l.downcase}}
327
+ # To preserve tags in the order in which they were added
328
+ # delete all current tags and create new tags if the content or order has changed
329
+ old_tags = (tags == current_tags ? [] : current_tags)
330
+ new_tags = (tags == current_tags ? [] : tags)
331
+ else
332
+ # Delete discarded tags and create new tags
333
+ old_tags = current_tags - tags
334
+ new_tags = tags - current_tags
335
+ end
336
+
222
337
  # Find taggings to remove:
223
- old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil,
224
- :context => context.to_s, :tag_id => old_tags).all
338
+ if old_tags.present?
339
+ old_taggings = taggings.where(:tagger_type => nil, :tagger_id => nil,
340
+ :context => context.to_s, :tag_id => old_tags).all
341
+ end
225
342
 
343
+ # Destroy old taggings:
226
344
  if old_taggings.present?
227
- # Destroy old taggings:
228
- ActsAsTaggableOn::Tagging.destroy_all :id => old_taggings.map(&:id)
345
+ ActsAsTaggableOn::Tagging.destroy_all "#{ActsAsTaggableOn::Tagging.primary_key}".to_sym => old_taggings.map(&:id)
229
346
  end
230
347
 
231
348
  # Create new taggings:
@@ -238,4 +355,4 @@ module ActsAsTaggableOn::Taggable
238
355
  end
239
356
  end
240
357
  end
241
- end
358
+ end
@@ -0,0 +1,37 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ module Dirty
3
+ def self.included(base)
4
+ base.extend ActsAsTaggableOn::Taggable::Dirty::ClassMethods
5
+
6
+ base.initialize_acts_as_taggable_on_dirty
7
+ end
8
+
9
+ module ClassMethods
10
+ def initialize_acts_as_taggable_on_dirty
11
+ tag_types.map(&:to_s).each do |tags_type|
12
+ tag_type = tags_type.to_s.singularize
13
+ context_tags = tags_type.to_sym
14
+
15
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
16
+ def #{tag_type}_list_changed?
17
+ changed_attributes.include?("#{tag_type}_list")
18
+ end
19
+
20
+ def #{tag_type}_list_was
21
+ changed_attributes.include?("#{tag_type}_list") ? changed_attributes["#{tag_type}_list"] : __send__("#{tag_type}_list")
22
+ end
23
+
24
+ def #{tag_type}_list_change
25
+ [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list")
26
+ end
27
+
28
+ def #{tag_type}_list_changes
29
+ [changed_attributes['#{tag_type}_list'], __send__('#{tag_type}_list')] if changed_attributes.include?("#{tag_type}_list")
30
+ end
31
+ RUBY
32
+
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end