acts-as-taggable-on 2.0.6 → 2.4.0

Sign up to get free protection for your applications and to get access to all the features.
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