make_taggable 0.7.4 → 1.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/CONTRIBUTING.md +65 -22
  4. data/LICENSE.md +18 -17
  5. data/README.md +73 -444
  6. data/UPGRADING.md +28 -2
  7. data/docs/caching.md +93 -0
  8. data/docs/configuration.md +108 -0
  9. data/docs/contexts.md +147 -0
  10. data/docs/database.md +106 -0
  11. data/docs/getting-started.md +114 -0
  12. data/docs/migrating-from-aato.md +67 -0
  13. data/docs/ownership.md +111 -0
  14. data/docs/parsers.md +109 -0
  15. data/docs/querying.md +163 -0
  16. data/docs/tag-clouds.md +74 -0
  17. data/lib/make_taggable/default_parser.rb +45 -32
  18. data/lib/make_taggable/engine.rb +6 -0
  19. data/lib/make_taggable/generic_parser.rb +30 -4
  20. data/lib/make_taggable/tag.rb +121 -19
  21. data/lib/make_taggable/tag_list.rb +77 -24
  22. data/lib/make_taggable/taggable/cache.rb +61 -11
  23. data/lib/make_taggable/taggable/collection.rb +102 -30
  24. data/lib/make_taggable/taggable/core.rb +154 -34
  25. data/lib/make_taggable/taggable/ownership.rb +82 -4
  26. data/lib/make_taggable/taggable/related.rb +66 -3
  27. data/lib/make_taggable/taggable/tag_list_type.rb +8 -0
  28. data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +10 -0
  29. data/lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb +10 -0
  30. data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +10 -0
  31. data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +15 -0
  32. data/lib/make_taggable/taggable/tagged_with_query.rb +17 -0
  33. data/lib/make_taggable/taggable.rb +34 -33
  34. data/lib/make_taggable/tagger.rb +73 -12
  35. data/lib/make_taggable/tagging.rb +30 -3
  36. data/lib/make_taggable/tags_helper.rb +22 -1
  37. data/lib/make_taggable/utils.rb +40 -5
  38. data/lib/make_taggable/version.rb +8 -1
  39. data/lib/make_taggable.rb +153 -8
  40. data/make_taggable.gemspec +13 -20
  41. metadata +50 -165
  42. data/.dummyrc +0 -17
  43. data/.github/workflows/ci.yml +0 -140
  44. data/.github/workflows/standard-ci.yml +0 -27
  45. data/.gitignore +0 -17
  46. data/.rspec +0 -3
  47. data/Appraisals +0 -15
  48. data/Gemfile +0 -16
  49. data/Rakefile +0 -13
  50. data/gemfiles/rails_5.gemfile +0 -9
  51. data/gemfiles/rails_6.gemfile +0 -9
  52. data/gemfiles/rails_6_1.gemfile +0 -9
  53. data/gemfiles/rails_master.gemfile +0 -9
  54. data/lib/tasks/setup_test_db.rake +0 -8
@@ -1,4 +1,21 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MakeTaggable
4
+ ##
5
+ # A tag name, shared by every record tagged with it.
6
+ #
7
+ # Tags are found and created through the class methods here rather than directly, so that the
8
+ # configured case sensitivity is applied consistently. Subclass it to keep a separate vocabulary
9
+ # for one context, and point at the subclass from
10
+ # {MakeTaggable::Taggable::Core#find_or_create_tags_from_list_with_context}.
11
+ #
12
+ # @!attribute [rw] name
13
+ # The tag itself.
14
+ # @return [String]
15
+ # @!attribute [rw] taggings_count
16
+ # How many taggings reference this tag, maintained as a counter cache.
17
+ # @return [Integer]
18
+ #
2
19
  class Tag < ::ActiveRecord::Base
3
20
  self.table_name = MakeTaggable.tags_table
4
21
 
@@ -10,7 +27,13 @@ module MakeTaggable
10
27
  validates_uniqueness_of :name, if: :validates_name_uniqueness?, case_sensitive: true
11
28
  validates_length_of :name, maximum: 255
12
29
 
13
- # Monkey patch this method if don't need name uniqueness validation
30
+ ##
31
+ # Whether the uniqueness validation on `name` runs.
32
+ #
33
+ # Override this in a subclass to allow tag names to repeat.
34
+ #
35
+ # @return [TrueClass, FalseClass] always `true` here
36
+ #
14
37
  def validates_name_uniqueness?
15
38
  true
16
39
  end
@@ -19,26 +42,53 @@ module MakeTaggable
19
42
  scope :most_used, ->(limit = 20) { order("taggings_count desc").limit(limit) }
20
43
  scope :least_used, ->(limit = 20) { order("taggings_count asc").limit(limit) }
21
44
 
45
+ ##
46
+ # Tags matching a name exactly, honouring the configured case sensitivity.
47
+ #
48
+ # @param name [String] the name to match
49
+ # @return [ActiveRecord::Relation]
50
+ #
22
51
  def self.named(name)
23
52
  if MakeTaggable.strict_case_match
24
- where(["name = #{binary}?", as_8bit_ascii(name)])
53
+ where(["name = #{binary}?", name.to_s])
25
54
  else
26
- where(["LOWER(name) = LOWER(?)", as_8bit_ascii(unicode_downcase(name))])
55
+ where(["LOWER(name) = LOWER(?)", name.to_s.downcase])
27
56
  end
28
57
  end
29
58
 
59
+ ##
60
+ # Tags matching any of the given names exactly.
61
+ #
62
+ # @param list [Array<String>] the names to match
63
+ # @return [ActiveRecord::Relation]
64
+ #
30
65
  def self.named_any(list)
31
66
  clause = list.map { |tag|
32
- sanitize_sql_for_named_any(tag).force_encoding("BINARY")
67
+ sanitize_sql_for_named_any(tag)
33
68
  }.join(" OR ")
34
69
  where(clause)
35
70
  end
36
71
 
72
+ ##
73
+ # Tags whose name contains the given fragment.
74
+ #
75
+ # Case insensitive on PostgreSQL, which uses `ILIKE`; otherwise it follows the column's
76
+ # collation.
77
+ #
78
+ # @param name [String] the fragment to look for
79
+ # @return [ActiveRecord::Relation]
80
+ #
37
81
  def self.named_like(name)
38
82
  clause = ["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(name)}%"]
39
83
  where(clause)
40
84
  end
41
85
 
86
+ ##
87
+ # Tags whose name contains any of the given fragments.
88
+ #
89
+ # @param list [Array<String>] the fragments to look for
90
+ # @return [ActiveRecord::Relation]
91
+ #
42
92
  def self.named_like_any(list)
43
93
  clause = list.map { |tag|
44
94
  sanitize_sql(["name #{MakeTaggable::Utils.like_operator} ? ESCAPE '!'", "%#{MakeTaggable::Utils.escape_like(tag.to_s)}%"])
@@ -46,6 +96,15 @@ module MakeTaggable
46
96
  where(clause)
47
97
  end
48
98
 
99
+ ##
100
+ # Tags used in a given context, whatever the record they were applied to.
101
+ #
102
+ # @param context [String, Symbol] the tagging context
103
+ # @return [ActiveRecord::Relation]
104
+ #
105
+ # @example
106
+ # MakeTaggable::Tag.for_context(:skills)
107
+ #
49
108
  def self.for_context(context)
50
109
  joins(:taggings)
51
110
  .where(["#{MakeTaggable.taggings_table}.context = ?", context])
@@ -53,29 +112,61 @@ module MakeTaggable
53
112
  end
54
113
 
55
114
  ### CLASS METHODS:
115
+
116
+ ##
117
+ # Finds a tag by name, creating it when it does not exist yet.
118
+ #
119
+ # The name is matched in full. Honours the configured case sensitivity.
120
+ #
121
+ # @param name [String] the tag name
122
+ # @return [MakeTaggable::Tag]
123
+ #
124
+ # @example
125
+ # MakeTaggable::Tag.find_or_create_with_like_by_name("ruby")
126
+ #
56
127
  def self.find_or_create_with_like_by_name(name)
57
128
  if MakeTaggable.strict_case_match
58
129
  find_or_create_all_with_like_by_name([name]).first
59
130
  else
60
- named_like(name).first || create(name: name)
131
+ # Matching has to happen in Ruby's terms rather than the column's: the
132
+ # MySQL migration collates tag names as utf8mb4_bin, which would make a
133
+ # LIKE comparison case sensitive whatever strict_case_match says.
134
+ named(name).first || create(name: name)
61
135
  end
62
136
  end
63
137
 
138
+ ##
139
+ # Finds every tag in a list by name, creating those that do not exist yet.
140
+ #
141
+ # A competing write that takes a name first is retried up to three times before giving up.
142
+ #
143
+ # @param list [Array<String>] the tag names
144
+ # @return [Array<MakeTaggable::Tag>] in the order the names were given
145
+ # @raise [MakeTaggable::DuplicateTagError] when a name stays taken after three attempts
146
+ #
147
+ # @example
148
+ # MakeTaggable::Tag.find_or_create_all_with_like_by_name(%w[ruby rails])
149
+ #
64
150
  def self.find_or_create_all_with_like_by_name(*list)
65
151
  list = Array(list).flatten
66
152
 
67
153
  return [] if list.empty?
68
154
 
69
- existing_tags = named_any(list)
155
+ existing_tags = named_any(list).to_a
70
156
  list.map do |tag_name|
71
157
  tries ||= 3
72
158
  comparable_tag_name = comparable_name(tag_name)
73
159
  existing_tag = existing_tags.find { |tag| comparable_name(tag.name) == comparable_tag_name }
74
- existing_tag || create(name: tag_name)
160
+ next existing_tag if existing_tag
161
+
162
+ # Tags created earlier in this call have to stay visible to the names
163
+ # that follow, or a list holding both "Ruby" and "ruby" resolves to two
164
+ # rows even though the two names compare equal.
165
+ create(name: tag_name).tap { |tag| existing_tags << tag }
75
166
  rescue ActiveRecord::RecordNotUnique
76
167
  if (tries -= 1).positive?
77
168
  ActiveRecord::Base.connection.execute "ROLLBACK"
78
- existing_tags = named_any(list)
169
+ existing_tags = named_any(list).to_a
79
170
  retry
80
171
  end
81
172
 
@@ -84,14 +175,33 @@ module MakeTaggable
84
175
  end
85
176
 
86
177
  ### INSTANCE METHODS:
178
+
179
+ ##
180
+ # Compares tags by name, so a saved tag and an unsaved one with the same name are equal.
181
+ #
182
+ # @param other [Object] the object to compare against
183
+ # @return [TrueClass, FalseClass]
184
+ #
87
185
  def ==(other)
88
186
  super || (other.is_a?(Tag) && name == other.name)
89
187
  end
90
188
 
189
+ ##
190
+ # The tag's name, so a tag renders as itself in a view or a string.
191
+ #
192
+ # @return [String]
193
+ #
91
194
  def to_s
92
195
  name
93
196
  end
94
197
 
198
+ ##
199
+ # How many times this tag matched, on relations that select a count alongside the tag columns.
200
+ #
201
+ # Zero on a tag loaded without one.
202
+ #
203
+ # @return [Integer]
204
+ #
95
205
  def count
96
206
  read_attribute(:count).to_i
97
207
  end
@@ -103,7 +213,7 @@ module MakeTaggable
103
213
  if MakeTaggable.strict_case_match
104
214
  str
105
215
  else
106
- unicode_downcase(str.to_s)
216
+ str.to_s.downcase
107
217
  end
108
218
  end
109
219
 
@@ -111,19 +221,11 @@ module MakeTaggable
111
221
  MakeTaggable::Utils.using_mysql? ? "BINARY " : nil
112
222
  end
113
223
 
114
- def as_8bit_ascii(string)
115
- string.to_s.mb_chars
116
- end
117
-
118
- def unicode_downcase(string)
119
- as_8bit_ascii(string).downcase
120
- end
121
-
122
224
  def sanitize_sql_for_named_any(tag)
123
225
  if MakeTaggable.strict_case_match
124
- sanitize_sql(["name = #{binary}?", as_8bit_ascii(tag)])
226
+ sanitize_sql(["name = #{binary}?", tag.to_s])
125
227
  else
126
- sanitize_sql(["LOWER(name) = LOWER(?)", as_8bit_ascii(unicode_downcase(tag))])
228
+ sanitize_sql(["LOWER(name) = LOWER(?)", tag.to_s.downcase])
127
229
  end
128
230
  end
129
231
  end
@@ -1,22 +1,55 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_support/core_ext/module/delegation"
2
4
 
3
5
  module MakeTaggable
6
+ ##
7
+ # The list of tag names held against one context of one record.
8
+ #
9
+ # A tag list is an Array, so everything Array offers works on it. What it adds is parsing, and
10
+ # cleaning: blank entries are dropped, entries are converted to strings and stripped, and
11
+ # duplicates are removed according to the configured case sensitivity.
12
+ #
13
+ # @example
14
+ # list = MakeTaggable::TagList.new("Fun", "Happy")
15
+ # list.add("Sad, Lonely", parse: true)
16
+ # list # => ["Fun", "Happy", "Sad", "Lonely"]
17
+ #
18
+ # @!attribute [rw] owner
19
+ # The tagger whose tags these are, when the list belongs to an owner.
20
+ # @return [ActiveRecord::Base, NilClass]
21
+ # @!attribute [rw] parser
22
+ # The parser used when this list is asked to parse a string.
23
+ # @return [Class]
24
+ #
4
25
  class TagList < Array
5
26
  attr_accessor :owner
6
27
  attr_accessor :parser
7
28
 
29
+ ##
30
+ # Builds a tag list from the given names.
31
+ #
32
+ # @param args [Array<String, Symbol>] the tag names, optionally followed by an options hash
33
+ # accepted by {#add}
34
+ # @return [MakeTaggable::TagList]
35
+ #
8
36
  def initialize(*args)
9
37
  @parser = MakeTaggable.default_parser
10
38
  add(*args)
11
39
  end
12
40
 
13
41
  ##
14
- # Add tags to the tag_list. Duplicate or blank tags will be ignored.
15
- # Use the <tt>:parse</tt> option to add an unparsed tag string.
42
+ # Adds tags to the list, ignoring duplicates and blanks.
43
+ #
44
+ # @param names [Array<String, Symbol>] the tags to add, optionally followed by an options hash
45
+ # @option names [TrueClass, FalseClass] :parse whether to parse the input as a delimited string
46
+ # @option names [Class] :parser a parser to use for this call only
47
+ # @return [MakeTaggable::TagList] self, so calls can be chained
16
48
  #
17
- # Example:
49
+ # @example
18
50
  # tag_list.add("Fun", "Happy")
19
- # tag_list.add("Fun, Happy", :parse => true)
51
+ # tag_list.add("Fun, Happy", parse: true)
52
+ #
20
53
  def add(*names)
21
54
  extract_and_apply_options!(names)
22
55
  concat(names)
@@ -24,32 +57,48 @@ module MakeTaggable
24
57
  self
25
58
  end
26
59
 
27
- # Append---Add the tag to the tag_list. This
28
- # expression returns the tag_list itself, so several appends
29
- # may be chained together.
60
+ ##
61
+ # Adds one tag to the list.
62
+ #
63
+ # @param obj [String, Symbol] the tag to add
64
+ # @return [MakeTaggable::TagList] self, so appends can be chained
65
+ #
30
66
  def <<(obj)
31
67
  add(obj)
32
68
  end
33
69
 
34
- # Concatenation --- Returns a new tag list built by concatenating the
35
- # two tag lists together to produce a third tag list.
70
+ ##
71
+ # Joins two tag lists into a third, leaving both untouched.
72
+ #
73
+ # @param other [Array<String>] the tags to append
74
+ # @return [MakeTaggable::TagList] a new list
75
+ #
36
76
  def +(other)
37
77
  TagList.new.add(self).add(other)
38
78
  end
39
79
 
40
- # Appends the elements of +other_tag_list+ to +self+.
80
+ ##
81
+ # Appends another list's tags to this one.
82
+ #
83
+ # @param other_tag_list [Array<String>] the tags to append
84
+ # @return [MakeTaggable::TagList] self
85
+ #
41
86
  def concat(other_tag_list)
42
- super(other_tag_list).send(:clean!)
87
+ super.send(:clean!)
43
88
  self
44
89
  end
45
90
 
46
91
  ##
47
- # Remove specific tags from the tag_list.
48
- # Use the <tt>:parse</tt> option to add an unparsed tag string.
92
+ # Removes tags from the list.
93
+ #
94
+ # @param names [Array<String, Symbol>] the tags to remove, optionally followed by an options
95
+ # hash accepted by {#add}
96
+ # @return [MakeTaggable::TagList] self
49
97
  #
50
- # Example:
98
+ # @example
51
99
  # tag_list.remove("Sad", "Lonely")
52
- # tag_list.remove("Sad, Lonely", :parse => true)
100
+ # tag_list.remove("Sad, Lonely", parse: true)
101
+ #
53
102
  def remove(*names)
54
103
  extract_and_apply_options!(names)
55
104
  delete_if { |name| names.include?(name) }
@@ -57,20 +106,24 @@ module MakeTaggable
57
106
  end
58
107
 
59
108
  ##
60
- # Transform the tag_list into a tag string suitable for editing in a form.
61
- # The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
109
+ # Renders the list as a delimited string, suitable for a form field.
110
+ #
111
+ # Tags containing the delimiter are quoted, so the string parses back into the same list.
112
+ #
113
+ # @return [String]
114
+ #
115
+ # @example
116
+ # MakeTaggable::TagList.new("Round", "Square,Cube").to_s
117
+ # # => 'Round, "Square,Cube"'
62
118
  #
63
- # Example:
64
- # tag_list = TagList.new("Round", "Square,Cube")
65
- # tag_list.to_s # 'Round, "Square,Cube"'
66
119
  def to_s
67
120
  tags = frozen? ? dup : self
68
121
  tags.send(:clean!)
69
122
 
123
+ delimiter = Regexp.union(Array(MakeTaggable.delimiter))
124
+
70
125
  tags.map { |name|
71
- d = MakeTaggable.delimiter
72
- d = Regexp.new d.join("|") if d.is_a? Array
73
- name.index(d) ? "\"#{name}\"" : name
126
+ name.index(delimiter) ? "\"#{name}\"" : name
74
127
  }.join(MakeTaggable.glue)
75
128
  end
76
129
 
@@ -81,7 +134,7 @@ module MakeTaggable
81
134
  reject!(&:blank?)
82
135
  map!(&:to_s)
83
136
  map!(&:strip)
84
- map! { |tag| tag.mb_chars.downcase.to_s } if MakeTaggable.force_lowercase
137
+ map!(&:downcase) if MakeTaggable.force_lowercase
85
138
  map!(&:parameterize) if MakeTaggable.force_parameterize
86
139
 
87
140
  MakeTaggable.strict_case_match ? uniq! : uniq! { |tag| tag.downcase }
@@ -1,22 +1,39 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MakeTaggable::Taggable
4
+ ##
5
+ # Keeps a rendered copy of a tag list in a column on the taggable itself, so a list can be shown
6
+ # without loading its tags.
7
+ #
8
+ # Caching switches itself on for any context the model has a matching `cached_<context>_list`
9
+ # column for. Nothing else is required.
10
+ #
11
+ # @example Caching the default context
12
+ # add_column :books, :cached_tag_list, :string
13
+ #
2
14
  module Cache
15
+ ##
16
+ # @param base [Class] the model being made taggable
17
+ # @return [void]
18
+ #
19
+ # @api private
20
+ #
3
21
  def self.included(base)
4
22
  # When included, conditionally adds tag caching methods when the model
5
23
  # has any "cached_#{tag_type}_list" column
6
24
  base.extend Columns
7
25
  end
8
26
 
27
+ ##
28
+ # Intercepts the model's column lookup so caching can be wired up the first time the schema is
29
+ # known, without opening a database connection when the class is loaded.
30
+ #
9
31
  module Columns
10
- # ActiveRecord::Base.columns makes a database connection and caches the
11
- # calculated columns hash for the record as @columns. Since we don't
12
- # want to add caching methods until we confirm the presence of a
13
- # caching column, and we don't want to force opening a database
14
- # connection when the class is loaded, here we intercept and cache
15
- # the call to :columns as @make_taggable_cache_columns
16
- # to mimic the underlying behavior. While processing this first
17
- # call to columns, we do the caching column check and dynamically add
18
- # the class and instance methods
19
- # FIXME: this method cannot compile in rubinius
32
+ ##
33
+ # The model's columns, injecting the caching methods the first time they are asked for.
34
+ #
35
+ # @return [Array<ActiveRecord::ConnectionAdapters::Column>]
36
+ #
20
37
  def columns
21
38
  @make_taggable_cache_columns ||= begin
22
39
  db_columns = super
@@ -25,6 +42,11 @@ module MakeTaggable::Taggable
25
42
  end
26
43
  end
27
44
 
45
+ ##
46
+ # Forgets the cached column lookup along with Active Record's own.
47
+ #
48
+ # @return [void]
49
+ #
28
50
  def reset_column_information
29
51
  super
30
52
  @make_taggable_cache_columns = nil
@@ -51,7 +73,15 @@ module MakeTaggable::Taggable
51
73
  end
52
74
  end
53
75
 
76
+ ##
77
+ # Added to a model once it has at least one caching column.
78
+ #
54
79
  module ClassMethods
80
+ ##
81
+ # Defines a `caching_<context>_list?` predicate for each context.
82
+ #
83
+ # @return [void]
84
+ #
55
85
  def initialize_tags_cache
56
86
  tag_types.map(&:to_s).each do |tag_type|
57
87
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
@@ -62,17 +92,37 @@ module MakeTaggable::Taggable
62
92
  end
63
93
  end
64
94
 
95
+ ##
96
+ # Adds contexts and refreshes the caching predicates.
97
+ #
98
+ # @param args [Array<Symbol, String>] the contexts to add
99
+ # @return [void]
100
+ #
65
101
  def make_taggable(*args)
66
- super(*args)
102
+ super
67
103
  initialize_tags_cache
68
104
  end
69
105
 
106
+ ##
107
+ # Whether a context's tag list is cached on the model.
108
+ #
109
+ # @param context [Symbol, String] the tagging context
110
+ # @return [TrueClass, FalseClass]
111
+ #
70
112
  def caching_tag_list_on?(context)
71
113
  column_names.include?("cached_#{context.to_s.singularize}_list")
72
114
  end
73
115
  end
74
116
 
117
+ ##
118
+ # Added to a model once it has at least one caching column.
119
+ #
75
120
  module InstanceMethods
121
+ ##
122
+ # Writes each cached tag list into its column. Runs before save.
123
+ #
124
+ # @return [TrueClass]
125
+ #
76
126
  def save_cached_tag_list
77
127
  tag_types.map(&:to_s).each do |tag_type|
78
128
  if self.class.send("caching_#{tag_type.singularize}_list?")