acts-as-taggable-on 4.0.0 → 8.0.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 (71) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/spec.yml +95 -0
  3. data/.gitignore +1 -1
  4. data/Appraisals +12 -10
  5. data/CHANGELOG.md +206 -71
  6. data/CONTRIBUTING.md +13 -0
  7. data/Gemfile +1 -1
  8. data/README.md +79 -13
  9. data/acts-as-taggable-on.gemspec +2 -6
  10. data/db/migrate/1_acts_as_taggable_on_migration.rb +14 -8
  11. data/db/migrate/2_add_missing_unique_indices.rb +14 -9
  12. data/db/migrate/3_add_taggings_counter_cache_to_tags.rb +9 -4
  13. data/db/migrate/4_add_missing_taggable_index.rb +8 -3
  14. data/db/migrate/5_change_collation_for_tag_names.rb +7 -2
  15. data/db/migrate/6_add_missing_indexes_on_taggings.rb +22 -0
  16. data/db/migrate/7_add_tenant_to_taggings.rb +16 -0
  17. data/gemfiles/activerecord_5.0.gemfile +11 -5
  18. data/gemfiles/activerecord_5.1.gemfile +21 -0
  19. data/gemfiles/activerecord_5.2.gemfile +21 -0
  20. data/gemfiles/activerecord_6.0.gemfile +21 -0
  21. data/gemfiles/activerecord_6.1.gemfile +23 -0
  22. data/lib/acts-as-taggable-on.rb +6 -2
  23. data/lib/acts_as_taggable_on/tag.rb +23 -23
  24. data/lib/acts_as_taggable_on/tag_list.rb +1 -0
  25. data/lib/acts_as_taggable_on/taggable.rb +18 -1
  26. data/lib/acts_as_taggable_on/taggable/cache.rb +38 -34
  27. data/lib/acts_as_taggable_on/taggable/collection.rb +9 -7
  28. data/lib/acts_as_taggable_on/taggable/core.rb +49 -179
  29. data/lib/acts_as_taggable_on/taggable/ownership.rb +16 -5
  30. data/lib/acts_as_taggable_on/taggable/related.rb +1 -1
  31. data/lib/acts_as_taggable_on/taggable/tag_list_type.rb +4 -0
  32. data/lib/acts_as_taggable_on/taggable/tagged_with_query.rb +16 -0
  33. data/lib/acts_as_taggable_on/taggable/tagged_with_query/all_tags_query.rb +111 -0
  34. data/lib/acts_as_taggable_on/taggable/tagged_with_query/any_tags_query.rb +70 -0
  35. data/lib/acts_as_taggable_on/taggable/tagged_with_query/exclude_tags_query.rb +82 -0
  36. data/lib/acts_as_taggable_on/taggable/tagged_with_query/query_base.rb +61 -0
  37. data/lib/acts_as_taggable_on/tagger.rb +3 -3
  38. data/lib/acts_as_taggable_on/tagging.rb +9 -4
  39. data/lib/acts_as_taggable_on/utils.rb +4 -4
  40. data/lib/acts_as_taggable_on/version.rb +1 -2
  41. data/spec/acts_as_taggable_on/acts_as_taggable_on_spec.rb +4 -12
  42. data/spec/acts_as_taggable_on/caching_spec.rb +34 -10
  43. data/spec/acts_as_taggable_on/{taggable/dirty_spec.rb → dirty_spec.rb} +28 -13
  44. data/spec/acts_as_taggable_on/single_table_inheritance_spec.rb +28 -8
  45. data/spec/acts_as_taggable_on/tag_spec.rb +16 -1
  46. data/spec/acts_as_taggable_on/taggable_spec.rb +22 -15
  47. data/spec/acts_as_taggable_on/tagger_spec.rb +2 -2
  48. data/spec/acts_as_taggable_on/tagging_spec.rb +26 -0
  49. data/spec/internal/app/models/altered_inheriting_taggable_model.rb +2 -0
  50. data/spec/internal/app/models/cached_model_with_array.rb +6 -0
  51. data/spec/internal/app/models/columns_override_model.rb +5 -0
  52. data/spec/internal/app/models/company.rb +1 -1
  53. data/spec/internal/app/models/inheriting_taggable_model.rb +2 -0
  54. data/spec/internal/app/models/market.rb +1 -1
  55. data/spec/internal/app/models/non_standard_id_taggable_model.rb +1 -1
  56. data/spec/internal/app/models/student.rb +2 -0
  57. data/spec/internal/app/models/taggable_model.rb +3 -0
  58. data/spec/internal/app/models/user.rb +1 -1
  59. data/spec/internal/config/database.yml.sample +4 -8
  60. data/spec/internal/db/schema.rb +17 -5
  61. data/spec/spec_helper.rb +0 -1
  62. data/spec/support/database.rb +4 -4
  63. metadata +29 -68
  64. data/.travis.yml +0 -36
  65. data/UPGRADING.md +0 -8
  66. data/db/migrate/6_add_missing_indexes.rb +0 -12
  67. data/gemfiles/activerecord_4.0.gemfile +0 -16
  68. data/gemfiles/activerecord_4.1.gemfile +0 -16
  69. data/gemfiles/activerecord_4.2.gemfile +0 -15
  70. data/lib/acts_as_taggable_on/taggable/dirty.rb +0 -36
  71. data/spec/internal/app/models/models.rb +0 -90
@@ -27,13 +27,16 @@ module ActsAsTaggableOn::Taggable
27
27
  end
28
28
  end
29
29
 
30
- def owner_tags_on(owner, context)
30
+ def owner_tags(owner)
31
31
  if owner.nil?
32
- scope = base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ?), context.to_s])
32
+ scope = base_tags
33
33
  else
34
- scope = base_tags.where([%(#{ActsAsTaggableOn::Tagging.table_name}.context = ? AND
35
- #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = ? AND
36
- #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = ?), context.to_s, owner.id, owner.class.base_class.to_s])
34
+ scope = base_tags.where(
35
+ "#{ActsAsTaggableOn::Tagging.table_name}" => {
36
+ tagger_id: owner.id,
37
+ tagger_type: owner.class.base_class.to_s
38
+ }
39
+ )
37
40
  end
38
41
 
39
42
  # when preserving tag order, return tags in created order
@@ -45,6 +48,14 @@ module ActsAsTaggableOn::Taggable
45
48
  end
46
49
  end
47
50
 
51
+ def owner_tags_on(owner, context)
52
+ owner_tags(owner).where(
53
+ "#{ActsAsTaggableOn::Tagging.table_name}" => {
54
+ context: context
55
+ }
56
+ )
57
+ end
58
+
48
59
  def cached_owned_tag_list_on(context)
49
60
  variable_name = "@owned_#{context}_list"
50
61
  (instance_variable_defined?(variable_name) && instance_variable_get(variable_name)) || instance_variable_set(variable_name, {})
@@ -49,7 +49,7 @@ module ActsAsTaggableOn::Taggable
49
49
  private
50
50
 
51
51
  def exclude_self(klass, id)
52
- "#{klass.table_name}.#{klass.primary_key} != #{id} AND" if [self.class.base_class, self.class].include? klass
52
+ "#{klass.arel_table[klass.primary_key].not_eq(id).to_sql} AND" if [self.class.base_class, self.class].include? klass
53
53
  end
54
54
 
55
55
  def group_columns(klass)
@@ -0,0 +1,4 @@
1
+ module ActsAsTaggableOn::Taggable
2
+ class TagListType < ActiveModel::Type::Value
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ require_relative 'tagged_with_query/query_base'
2
+ require_relative 'tagged_with_query/exclude_tags_query'
3
+ require_relative 'tagged_with_query/any_tags_query'
4
+ require_relative 'tagged_with_query/all_tags_query'
5
+
6
+ module ActsAsTaggableOn::Taggable::TaggedWithQuery
7
+ def self.build(taggable_model, tag_model, tagging_model, tag_list, options)
8
+ if options[:exclude].present?
9
+ ExcludeTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build
10
+ elsif options[:any].present?
11
+ AnyTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build
12
+ else
13
+ AllTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,111 @@
1
+ module ActsAsTaggableOn::Taggable::TaggedWithQuery
2
+ class AllTagsQuery < QueryBase
3
+ def build
4
+ taggable_model.joins(each_tag_in_list)
5
+ .group(by_taggable)
6
+ .having(tags_that_matches_count)
7
+ .order(order_conditions)
8
+ .readonly(false)
9
+ end
10
+
11
+ private
12
+
13
+ def each_tag_in_list
14
+ arel_join = taggable_arel_table
15
+
16
+ tag_list.each do |tag|
17
+ tagging_alias = tagging_arel_table.alias(tagging_alias(tag))
18
+ arel_join = arel_join
19
+ .join(tagging_alias)
20
+ .on(on_conditions(tag, tagging_alias))
21
+ end
22
+
23
+ if options[:match_all].present?
24
+ arel_join = arel_join
25
+ .join(tagging_arel_table, Arel::Nodes::OuterJoin)
26
+ .on(
27
+ match_all_on_conditions
28
+ )
29
+ end
30
+
31
+ return arel_join.join_sources
32
+ end
33
+
34
+ def on_conditions(tag, tagging_alias)
35
+ on_condition = tagging_alias[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
36
+ .and(tagging_alias[:taggable_type].eq(taggable_model.base_class.name))
37
+ .and(
38
+ tagging_alias[:tag_id].in(
39
+ tag_arel_table.project(tag_arel_table[:id]).where(tag_match_type(tag))
40
+ )
41
+ )
42
+
43
+ if options[:start_at].present?
44
+ on_condition = on_condition.and(tagging_alias[:created_at].gteq(options[:start_at]))
45
+ end
46
+
47
+ if options[:end_at].present?
48
+ on_condition = on_condition.and(tagging_alias[:created_at].lteq(options[:end_at]))
49
+ end
50
+
51
+ if options[:on].present?
52
+ on_condition = on_condition.and(tagging_alias[:context].eq(options[:on]))
53
+ end
54
+
55
+ if (owner = options[:owned_by]).present?
56
+ on_condition = on_condition.and(tagging_alias[:tagger_id].eq(owner.id))
57
+ .and(tagging_alias[:tagger_type].eq(owner.class.base_class.to_s))
58
+ end
59
+
60
+ on_condition
61
+ end
62
+
63
+ def match_all_on_conditions
64
+ on_condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
65
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
66
+
67
+ if options[:start_at].present?
68
+ on_condition = on_condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
69
+ end
70
+
71
+ if options[:end_at].present?
72
+ on_condition = on_condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
73
+ end
74
+
75
+ if options[:on].present?
76
+ on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
77
+ end
78
+
79
+ on_condition
80
+ end
81
+
82
+ def by_taggable
83
+ return [] unless options[:match_all].present?
84
+
85
+ taggable_arel_table[taggable_model.primary_key]
86
+ end
87
+
88
+ def tags_that_matches_count
89
+ return [] unless options[:match_all].present?
90
+
91
+ taggable_model.find_by_sql(tag_arel_table.project(Arel.star.count).where(tags_match_type).to_sql)
92
+
93
+ tagging_arel_table[:taggable_id].count.eq(
94
+ tag_arel_table.project(Arel.star.count).where(tags_match_type)
95
+ )
96
+ end
97
+
98
+ def order_conditions
99
+ order_by = []
100
+ order_by << tagging_arel_table.project(tagging_arel_table[Arel.star].count.as('taggings_count')).order('taggings_count DESC').to_sql if options[:order_by_matching_tag_count].present? && options[:match_all].blank?
101
+
102
+ order_by << options[:order] if options[:order].present?
103
+ order_by.join(', ')
104
+ end
105
+
106
+ def tagging_alias(tag)
107
+ alias_base_name = taggable_model.base_class.name.downcase
108
+ adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tag)}")
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,70 @@
1
+ module ActsAsTaggableOn::Taggable::TaggedWithQuery
2
+ class AnyTagsQuery < QueryBase
3
+ def build
4
+ taggable_model.select(all_fields)
5
+ .where(model_has_at_least_one_tag)
6
+ .order(Arel.sql(order_conditions))
7
+ .readonly(false)
8
+ end
9
+
10
+ private
11
+
12
+ def all_fields
13
+ taggable_arel_table[Arel.star]
14
+ end
15
+
16
+ def model_has_at_least_one_tag
17
+ tagging_arel_table.project(Arel.star).where(at_least_one_tag).exists
18
+ end
19
+
20
+ def at_least_one_tag
21
+ exists_contition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
22
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
23
+ .and(
24
+ tagging_arel_table[:tag_id].in(
25
+ tag_arel_table.project(tag_arel_table[:id]).where(tags_match_type)
26
+ )
27
+ )
28
+
29
+ if options[:start_at].present?
30
+ exists_contition = exists_contition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
31
+ end
32
+
33
+ if options[:end_at].present?
34
+ exists_contition = exists_contition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
35
+ end
36
+
37
+ if options[:on].present?
38
+ exists_contition = exists_contition.and(tagging_arel_table[:context].eq(options[:on]))
39
+ end
40
+
41
+ if (owner = options[:owned_by]).present?
42
+ exists_contition = exists_contition.and(tagging_arel_table[:tagger_id].eq(owner.id))
43
+ .and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
44
+ end
45
+
46
+ exists_contition
47
+ end
48
+
49
+ def order_conditions
50
+ order_by = []
51
+ if options[:order_by_matching_tag_count].present?
52
+ order_by << "(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{at_least_one_tag.to_sql}) desc"
53
+ end
54
+
55
+ order_by << options[:order] if options[:order].present?
56
+ order_by.join(', ')
57
+ end
58
+
59
+ def alias_name(tag_list)
60
+ alias_base_name = taggable_model.base_class.name.downcase
61
+ taggings_context = options[:on] ? "_#{options[:on]}" : ''
62
+
63
+ taggings_alias = adjust_taggings_alias(
64
+ "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tag_list.join('_'))}"
65
+ )
66
+
67
+ taggings_alias
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,82 @@
1
+ module ActsAsTaggableOn::Taggable::TaggedWithQuery
2
+ class ExcludeTagsQuery < QueryBase
3
+ def build
4
+ taggable_model.joins(owning_to_tagger)
5
+ .where(tags_not_in_list)
6
+ .having(tags_that_matches_count)
7
+ .readonly(false)
8
+ end
9
+
10
+ private
11
+
12
+ def tags_not_in_list
13
+ return taggable_arel_table[:id].not_in(
14
+ tagging_arel_table
15
+ .project(tagging_arel_table[:taggable_id])
16
+ .join(tag_arel_table)
17
+ .on(
18
+ tagging_arel_table[:tag_id].eq(tag_arel_table[:id])
19
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
20
+ .and(tags_match_type)
21
+ )
22
+ )
23
+
24
+ # FIXME: missing time scope, this is also missing in the original implementation
25
+ end
26
+
27
+
28
+ def owning_to_tagger
29
+ return [] unless options[:owned_by].present?
30
+
31
+ owner = options[:owned_by]
32
+
33
+ arel_join = taggable_arel_table
34
+ .join(tagging_arel_table)
35
+ .on(
36
+ tagging_arel_table[:tagger_id].eq(owner.id)
37
+ .and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
38
+ .and(tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key]))
39
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
40
+ )
41
+
42
+ if options[:match_all].present?
43
+ arel_join = arel_join
44
+ .join(tagging_arel_table, Arel::Nodes::OuterJoin)
45
+ .on(
46
+ match_all_on_conditions
47
+ )
48
+ end
49
+
50
+ return arel_join.join_sources
51
+ end
52
+
53
+ def match_all_on_conditions
54
+ on_condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
55
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
56
+
57
+ if options[:start_at].present?
58
+ on_condition = on_condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
59
+ end
60
+
61
+ if options[:end_at].present?
62
+ on_condition = on_condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
63
+ end
64
+
65
+ if options[:on].present?
66
+ on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
67
+ end
68
+
69
+ on_condition
70
+ end
71
+
72
+ def tags_that_matches_count
73
+ return [] unless options[:match_all].present?
74
+
75
+ taggable_model.find_by_sql(tag_arel_table.project(Arel.star.count).where(tags_match_type).to_sql)
76
+
77
+ tagging_arel_table[:taggable_id].count.eq(
78
+ tag_arel_table.project(Arel.star.count).where(tags_match_type)
79
+ )
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,61 @@
1
+ module ActsAsTaggableOn::Taggable::TaggedWithQuery
2
+ class QueryBase
3
+ def initialize(taggable_model, tag_model, tagging_model, tag_list, options)
4
+ @taggable_model = taggable_model
5
+ @tag_model = tag_model
6
+ @tagging_model = tagging_model
7
+ @tag_list = tag_list
8
+ @options = options
9
+ end
10
+
11
+ private
12
+
13
+ attr_reader :taggable_model, :tag_model, :tagging_model, :tag_list, :options
14
+
15
+ def taggable_arel_table
16
+ @taggable_arel_table ||= taggable_model.arel_table
17
+ end
18
+
19
+ def tag_arel_table
20
+ @tag_arel_table ||= tag_model.arel_table
21
+ end
22
+
23
+ def tagging_arel_table
24
+ @tagging_arel_table ||=tagging_model.arel_table
25
+ end
26
+
27
+ def tag_match_type(tag)
28
+ matches_attribute = tag_arel_table[:name]
29
+ matches_attribute = matches_attribute.lower unless ActsAsTaggableOn.strict_case_match
30
+
31
+ if options[:wild].present?
32
+ matches_attribute.matches("%#{escaped_tag(tag)}%", "!", ActsAsTaggableOn.strict_case_match)
33
+ else
34
+ matches_attribute.matches(escaped_tag(tag), "!", ActsAsTaggableOn.strict_case_match)
35
+ end
36
+ end
37
+
38
+ def tags_match_type
39
+ matches_attribute = tag_arel_table[:name]
40
+ matches_attribute = matches_attribute.lower unless ActsAsTaggableOn.strict_case_match
41
+
42
+ if options[:wild].present?
43
+ matches_attribute.matches_any(tag_list.map{|tag| "%#{escaped_tag(tag)}%"}, "!", ActsAsTaggableOn.strict_case_match)
44
+ else
45
+ matches_attribute.matches_any(tag_list.map{|tag| "#{escaped_tag(tag)}"}, "!", ActsAsTaggableOn.strict_case_match)
46
+ end
47
+ end
48
+
49
+ def escaped_tag(tag)
50
+ tag = tag.downcase unless ActsAsTaggableOn.strict_case_match
51
+ ActsAsTaggableOn::Utils.escape_like(tag)
52
+ end
53
+
54
+ def adjust_taggings_alias(taggings_alias)
55
+ if taggings_alias.size > 75
56
+ taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias)
57
+ end
58
+ taggings_alias
59
+ end
60
+ end
61
+ end
@@ -18,14 +18,14 @@ module ActsAsTaggableOn
18
18
  owned_taggings_scope = opts.delete(:scope)
19
19
 
20
20
  has_many :owned_taggings, owned_taggings_scope,
21
- opts.merge(
21
+ **opts.merge(
22
22
  as: :tagger,
23
- class_name: ::ActsAsTaggableOn::Tagging,
23
+ class_name: '::ActsAsTaggableOn::Tagging',
24
24
  dependent: :destroy
25
25
  )
26
26
 
27
27
  has_many :owned_tags, -> { distinct },
28
- class_name: ::ActsAsTaggableOn::Tag,
28
+ class_name: '::ActsAsTaggableOn::Tag',
29
29
  source: :tag,
30
30
  through: :owned_taggings
31
31
  end
@@ -1,15 +1,20 @@
1
1
  module ActsAsTaggableOn
2
2
  class Tagging < ::ActiveRecord::Base #:nodoc:
3
+ self.table_name = ActsAsTaggableOn.taggings_table
4
+
5
+ DEFAULT_CONTEXT = 'tags'
3
6
  belongs_to :tag, class_name: '::ActsAsTaggableOn::Tag', counter_cache: ActsAsTaggableOn.tags_counter
4
7
  belongs_to :taggable, polymorphic: true
5
8
 
6
- belongs_to :tagger, {polymorphic: true}.tap {|o| o.merge!(optional: true) if ActsAsTaggableOn::Utils.active_record5? }
9
+ belongs_to :tagger, polymorphic: true, optional: true
7
10
 
8
11
  scope :owned_by, ->(owner) { where(tagger: owner) }
9
12
  scope :not_owned, -> { where(tagger_id: nil, tagger_type: nil) }
10
13
 
11
- scope :by_contexts, ->(contexts) { where(context: (contexts || 'tags')) }
12
- scope :by_context, ->(context = 'tags') { by_contexts(context.to_s) }
14
+ scope :by_contexts, ->(contexts) { where(context: (contexts || DEFAULT_CONTEXT)) }
15
+ scope :by_context, ->(context = DEFAULT_CONTEXT) { by_contexts(context.to_s) }
16
+
17
+ scope :by_tenant, ->(tenant) { where(tenant: tenant) }
13
18
 
14
19
  validates_presence_of :context
15
20
  validates_presence_of :tag_id
@@ -25,7 +30,7 @@ module ActsAsTaggableOn
25
30
  if ActsAsTaggableOn.tags_counter
26
31
  tag.destroy if tag.reload.taggings_count.zero?
27
32
  else
28
- tag.destroy if tag.reload.taggings.count.zero?
33
+ tag.destroy if tag.reload.taggings.none?
29
34
  end
30
35
  end
31
36
  end