make_taggable 1.4.0 → 1.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b40ab74ad5312aa300ba26015e4fc7d5da67e5e37c5f4c7efb235aa494e28d0
4
- data.tar.gz: 84db5687e59dfb33259222e47bc58fdb7d94dd35882faafc2f1ceede7effc841
3
+ metadata.gz: 1d900a8ac37e0f88ead6c0516fd30ddee5e8cc11c893b7d64e85c090a8b59598
4
+ data.tar.gz: b364eef565e3a171c56558f928360de5230c445b4b210e1aab8f536f9b998c70
5
5
  SHA512:
6
- metadata.gz: a71b99737d5b1f09b1fe76ffa1b5710c50f6330d1222d107bdfd685c966f6c4681040a688d29df8427b5cf3e91e1d29c458d00085de110187272afb925393ebb
7
- data.tar.gz: 6ce73b0b7e0497f084080f1d4a774c974f2acb0c94ffec9d90bdbe8326f4a1e2f28721f5b4372c7d7b71ddbe0c567490faec4eb22cf884b9723fc39cddc579f0
6
+ metadata.gz: 611fd4635e580ff5aaaee314600d2d66e7cdb70a2bd22d8988e3eb541c3d632fc25625e485880821beeec8f0bfb71677242f3d0d14ba96e98e0dd4d15038e0ae
7
+ data.tar.gz: b1713ea6a65eb7a8e45dfb0d4b8d41c9d72334cfb8de93ef2c1e60a21a6a4e04758acdcb7761a117b8b275f0aecb3e98d78258a8c7f8389f433836353e676958
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
6
6
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2026-08-23
9
+
10
+ ### Added
11
+
12
+ - `:on` accepts several contexts as well as one, so a query can search a subset:
13
+ `tagged_with("classic", on: [:genres, :moods], any: true)`. Passing an array previously raised
14
+ `TypeError` from inside Arel.
15
+
16
+ ### Fixed
17
+
18
+ - `force_parameterize` discarded a tag outright when the name had no ASCII in it. `parameterize`
19
+ reduces such a name to an empty string, which was then rejected as blank, so a user tagging in
20
+ Japanese, Greek, Hebrew, Arabic or Cyrillic saved successfully and got back fewer tags than they
21
+ typed. The parameterized form is now kept only where there is one.
22
+
23
+ - `force_binary_collation = true` ran an `ALTER TABLE` every time it was assigned, with no check for
24
+ whether the column already carried that collation. The documented home for it is an initializer,
25
+ which runs once per process, so a deployment issued a schema change against the `tags` table from
26
+ every web worker, background worker, console and rake task -- each one taking a metadata lock that
27
+ other queries queue behind. The current collation is now read first and the statement skipped when
28
+ it matches. The blanket `rescue`/`puts` around it is replaced by an explicit `table_exists?` check.
29
+
30
+ - On PostgreSQL, tag names were matched as `LOWER(name) ILIKE ...`. `ILIKE` already folds case, so
31
+ the `LOWER()` changed nothing while making the expression non-sargable -- no index on `tags.name`
32
+ could be used, including a trigram index built for wild searches. It is skipped on PostgreSQL and
33
+ kept on the adapters that need it.
34
+
35
+ - Taggings are created in tag id order. Each insert bumps the tag's counter cache, so two concurrent
36
+ saves touching the same tags took row locks in whatever order their lists happened to be in, which
37
+ invites deadlocks. Models using `make_ordered_taggable` are unaffected -- there the creation order
38
+ carries the meaning.
39
+
8
40
  ## [1.4.0] - 2026-08-23
9
41
 
10
42
  ### Fixed
@@ -40,6 +40,10 @@ Tags are downcased as they are cleaned, so `"Ruby"` is stored as `"ruby"`.
40
40
  Tags are parameterized, so `"Ruby on Rails"` is stored as `"ruby-on-rails"`. Applied after
41
41
  `force_lowercase` if both are on.
42
42
 
43
+ `String#parameterize` strips anything outside a conservative ASCII set, so a name with no ASCII in
44
+ it -- `"日本語"` -- has no slug to reduce to. Such a name is kept as it is rather than parameterized
45
+ into nothing and dropped.
46
+
43
47
  ### `strict_case_match`
44
48
 
45
49
  Off, tag lookups are case insensitive and `"Ruby"` finds `"ruby"`; a list containing both keeps one.
@@ -98,6 +102,12 @@ Note that the shipped migrations already apply `utf8mb4_bin` to the column on My
98
102
  setting adds is `strict_case_match`, which is what actually makes the library's lookups case
99
103
  sensitive — see [database.md](database.md).
100
104
 
105
+ Because the migrations have usually applied the collation already, assigning this in an initializer
106
+ is normally a no-op: the current collation is read first and the `ALTER TABLE` is skipped when it
107
+ already matches. That matters because an initializer runs once per process — every web worker,
108
+ background worker, console and rake task — and each `ALTER TABLE` takes a metadata lock on the tags
109
+ table.
110
+
101
111
  ```ruby
102
112
  MakeTaggable.force_binary_collation = true
103
113
  ```
data/docs/database.md CHANGED
@@ -107,8 +107,25 @@ end
107
107
  ## PostgreSQL
108
108
 
109
109
  - `named_like` uses `ILIKE`, so partial matching is case insensitive regardless of collation.
110
- - Tag counts group by every tag column, as PostgreSQL requires.
111
- - Nothing extra is needed for non-ASCII tags.
110
+ - Queries group by the primary key. PostgreSQL wanted every selected non-aggregated column listed
111
+ before 9.1; since then it works the dependency out itself.
112
+
113
+ **Case-insensitive matching of non-ASCII tags depends on the locale the cluster was created with.**
114
+ Exact matching goes through `LOWER(name) = LOWER(?)`, and `LOWER()` follows `lc_ctype`. On a UTF-8
115
+ locale it folds Cyrillic, Greek and the rest as you would expect. On a cluster initialised with
116
+ `lc_ctype = C` it folds ASCII only, and `"ПРИВЕТ"` and `"привет"` become two separate tags — the same
117
+ limitation described under SQLite below, on a database that otherwise has none.
118
+
119
+ Check with:
120
+
121
+ ```sql
122
+ SHOW lc_ctype; -- C means ASCII-only folding
123
+ SELECT LOWER('Ü'); -- returns 'Ü' unchanged on such a cluster
124
+ ```
125
+
126
+ `ILIKE` is unaffected, so partial matching keeps working either way. If you need exact matching to
127
+ fold non-ASCII, create the database with a UTF-8 locale, or set
128
+ `MakeTaggable.strict_case_match = true` so the behaviour is at least consistent.
112
129
 
113
130
  ## MySQL
114
131
 
data/docs/querying.md CHANGED
@@ -28,12 +28,18 @@ empty relation rather than every record — worth knowing when the tags come fro
28
28
  | `:exclude` | Match records carrying none of the tags |
29
29
  | `:match_all` | Match records carrying only these tags and no others |
30
30
  | `:wild` | Match tags *containing* the given text, i.e. `%sci%` |
31
- | `:on` | Restrict to one context. Honoured by every option, `:exclude` included |
31
+ | `:on` | Restrict to one context, or an array of them. Honoured by every option, `:exclude` included |
32
32
  | `:owned_by` | Restrict to tags applied by one tagger |
33
33
  | `:order_by_matching_tag_count` | Order by how many matching taggings a record has, most first. No effect with `:match_all` |
34
34
  | `:start_at` | Only tags applied after this time. Honoured by every option, `:exclude` included |
35
35
  | `:end_at` | Only tags applied before this time. Honoured by every option, `:exclude` included |
36
36
 
37
+ `:on` takes several contexts as well as one, for searching a subset:
38
+
39
+ ```ruby
40
+ Book.tagged_with("classic", on: [:genres, :moods], any: true)
41
+ ```
42
+
37
43
  An empty tag list means "nothing matches" for the matching options and "nothing is ruled out" for
38
44
  `:exclude`, so the two always partition the scope between them:
39
45
 
@@ -183,6 +189,14 @@ its own — add `.distinct` if you want records back rather than matches.
183
189
  is still worth reaching for `any: true` where the semantics allow it.
184
190
  - Saving a taggable reads nothing from the taggings table unless a tag list was actually assigned,
185
191
  so a save that touches no tags costs no extra query and works under `strict_loading`.
192
+ - On PostgreSQL, tag names are matched with `ILIKE` against the column itself rather than through
193
+ `LOWER()`, so an index on `tags.name` can be used. A wild search is the case that most wants one:
194
+
195
+ ```sql
196
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
197
+ CREATE INDEX index_tags_on_name_trgm ON tags USING gin (name gin_trgm_ops);
198
+ ```
199
+
186
200
  - `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
187
201
  page of results and expensive across a whole table.
188
202
  - `all_tag_counts` joins the taggables to count them. Reach for `all_tags` when the counts are not
@@ -158,7 +158,11 @@ module MakeTaggable
158
158
  map!(&:to_s)
159
159
  map!(&:strip)
160
160
  map!(&:downcase) if MakeTaggable.force_lowercase
161
- map!(&:parameterize) if MakeTaggable.force_parameterize
161
+ # A name with no ASCII in it parameterizes to "", and reject! below would
162
+ # then drop it -- so the tag vanished rather than being slugged. Keep the
163
+ # original where there is no slug to be had; a caller who wanted strict
164
+ # slugs still gets one wherever one exists.
165
+ map! { |tag| tag.parameterize.presence || tag } if MakeTaggable.force_parameterize
162
166
 
163
167
  MakeTaggable.strict_case_match ? uniq! : uniq! { |tag| tag.downcase }
164
168
  self
@@ -118,7 +118,17 @@ module MakeTaggable::Taggable
118
118
  initialize_make_taggable_core
119
119
  end
120
120
 
121
- # all column names are necessary for PostgreSQL group clause
121
+ ##
122
+ # Every column on a table, qualified, joined for a `GROUP BY` clause.
123
+ #
124
+ # Nothing in the library needs this any more. PostgreSQL wanted every selected
125
+ # non-aggregated column in the `GROUP BY` before 9.1; since then the primary key is enough,
126
+ # and that is what the library groups by. Kept because it is public and useful for building
127
+ # such a clause by hand.
128
+ #
129
+ # @param object [Class] the model whose columns to list
130
+ # @return [String]
131
+ #
122
132
  def grouped_column_names_for(object)
123
133
  object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
124
134
  end
@@ -179,7 +189,12 @@ module MakeTaggable::Taggable
179
189
  end
180
190
  end
181
191
 
182
- # all column names are necessary for PostgreSQL group clause
192
+ ##
193
+ # @see ClassMethods#grouped_column_names_for
194
+ #
195
+ # @param object [Class] the model whose columns to list
196
+ # @return [String]
197
+ #
183
198
  def grouped_column_names_for(object)
184
199
  self.class.grouped_column_names_for(object)
185
200
  end
@@ -442,11 +457,15 @@ module MakeTaggable::Taggable
442
457
  opts = ["#{tagging_table_name}.context = ?", context.to_s]
443
458
  scope = base_tags.where(opts)
444
459
 
460
+ group_columns = "#{MakeTaggable::Tag.table_name}.#{MakeTaggable::Tag.primary_key}"
461
+
445
462
  if MakeTaggable::Utils.using_postgresql?
446
- group_columns = grouped_column_names_for(MakeTaggable::Tag)
463
+ # Ordering by an aggregate is why this groups at all. Grouping by the
464
+ # primary key alone is enough on every supported PostgreSQL -- it works
465
+ # the functional dependency out itself, and has since 9.1.
447
466
  scope.order(Arel.sql("max(#{tagging_table_name}.created_at)")).group(group_columns)
448
467
  else
449
- scope.group("#{MakeTaggable::Tag.table_name}.#{MakeTaggable::Tag.primary_key}")
468
+ scope.group(group_columns)
450
469
  end.to_a
451
470
  end
452
471
 
@@ -587,7 +606,13 @@ module MakeTaggable::Taggable
587
606
  taggings.not_owned.by_context(context).where(tag_id: old_tags).destroy_all
588
607
  end
589
608
 
590
- # Create new taggings:
609
+ # Create new taggings, in a consistent order. Each insert bumps the tag's
610
+ # counter cache, so two concurrent saves touching the same tags would
611
+ # otherwise take row locks in whatever order their lists happened to be
612
+ # in, and deadlock. Ordering is skipped where the model asked for tag
613
+ # order to be preserved, since there the creation order is the point.
614
+ new_tags = new_tags.sort_by(&:id) unless self.class.preserve_tag_order?
615
+
591
616
  new_tags.each do |tag|
592
617
  taggings.create!(tag_id: tag.id, context: context.to_s, taggable: self)
593
618
  end
@@ -202,7 +202,10 @@ module MakeTaggable::Taggable
202
202
  tag_id: old_tags, context: context).destroy_all
203
203
  end
204
204
 
205
- # Create new taggings:
205
+ # Create new taggings, in a consistent order -- see the note in
206
+ # Core#save_tags on why the order matters.
207
+ new_tags = new_tags.sort_by(&:id) unless self.class.preserve_tag_order?
208
+
206
209
  new_tags.each do |tag|
207
210
  taggings.create!(tag_id: tag.id, context: context.to_s, tagger: owner, taggable: self)
208
211
  end
@@ -56,7 +56,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
56
56
  end
57
57
 
58
58
  if options[:on].present?
59
- condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
59
+ condition = condition.and(context_predicate)
60
60
  end
61
61
 
62
62
  if (owner = options[:owned_by]).present?
@@ -92,7 +92,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
92
92
  end
93
93
 
94
94
  if options[:on].present?
95
- on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
95
+ on_condition = on_condition.and(context_predicate)
96
96
  end
97
97
 
98
98
  on_condition
@@ -28,7 +28,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
28
28
  # Left off, the subquery gathers taggings from other contexts and other
29
29
  # times, and excludes records on the strength of them.
30
30
  if options[:on].present?
31
- on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
31
+ on_condition = on_condition.and(context_predicate)
32
32
  end
33
33
 
34
34
  if options[:start_at].present?
@@ -85,7 +85,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
85
85
  end
86
86
 
87
87
  if options[:on].present?
88
- on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
88
+ on_condition = on_condition.and(context_predicate)
89
89
  end
90
90
 
91
91
  on_condition
@@ -40,8 +40,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
40
40
  end
41
41
 
42
42
  def tag_match_type(tag)
43
- matches_attribute = tag_arel_table[:name]
44
- matches_attribute = matches_attribute.lower unless MakeTaggable.strict_case_match
43
+ matches_attribute = folded_name_attribute
45
44
 
46
45
  if options[:wild].present?
47
46
  matches_attribute.matches("%#{escaped_tag(tag)}%", "!", MakeTaggable.strict_case_match)
@@ -51,8 +50,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
51
50
  end
52
51
 
53
52
  def tags_match_type
54
- matches_attribute = tag_arel_table[:name]
55
- matches_attribute = matches_attribute.lower unless MakeTaggable.strict_case_match
53
+ matches_attribute = folded_name_attribute
56
54
 
57
55
  if options[:wild].present?
58
56
  matches_attribute.matches_any(tag_list.map { |tag| "%#{escaped_tag(tag)}%" }, "!", MakeTaggable.strict_case_match)
@@ -85,7 +83,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
85
83
  end
86
84
 
87
85
  if options[:on].present?
88
- condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
86
+ condition = condition.and(context_predicate)
89
87
  end
90
88
 
91
89
  if (owner = options[:owned_by]).present?
@@ -101,6 +99,32 @@ module MakeTaggable::Taggable::TaggedWithQuery
101
99
  "(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{matching_taggings.to_sql}) desc"
102
100
  end
103
101
 
102
+ # The predicate restricting a query to the context or contexts asked for.
103
+ #
104
+ # `:on` takes one context or several, so a caller can search a subset without either naming a
105
+ # single one or falling back to every context there is.
106
+ def context_predicate(tagging_table = tagging_arel_table)
107
+ contexts = Array(options[:on]).map(&:to_s)
108
+
109
+ contexts.one? ? tagging_table[:context].eq(contexts.first) : tagging_table[:context].in(contexts)
110
+ end
111
+
112
+ # The tag name attribute to match against, folded where the comparison will not fold it itself.
113
+ #
114
+ # PostgreSQL matches with ILIKE, which is already case-insensitive, so wrapping the column in
115
+ # LOWER() there changes nothing and costs everything -- the expression stops being sargable, so
116
+ # no index on tags.name can be used, including a trigram index built for wild searches.
117
+ #
118
+ # The other adapters do need it. The MySQL migration collates tags.name as utf8mb4_bin, which
119
+ # makes LIKE case-sensitive, and folding keeps SQLite consistent with the rest.
120
+ def folded_name_attribute
121
+ attribute = tag_arel_table[:name]
122
+
123
+ return attribute if MakeTaggable.strict_case_match || MakeTaggable::Utils.using_postgresql?
124
+
125
+ attribute.lower
126
+ end
127
+
104
128
  def escaped_tag(tag)
105
129
  tag = tag.downcase unless MakeTaggable.strict_case_match
106
130
  MakeTaggable::Utils.escape_like(tag)
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.4.0"
9
+ VERSION = "1.5.0"
10
10
  end
data/lib/make_taggable.rb CHANGED
@@ -254,16 +254,40 @@ module MakeTaggable
254
254
  # `utf8mb4_general_ci`
255
255
  # @return [NilClass]
256
256
  #
257
+ ##
258
+ # Applies the collation `tags.name` should carry on MySQL, if it does not carry it already.
259
+ #
260
+ # This is a schema change, and the documented way to reach it is an initializer -- which runs
261
+ # once per process, so once per web worker, background worker, console and rake task. Issuing
262
+ # `ALTER TABLE` from each of those takes a metadata lock on the tags table every time. So the
263
+ # current collation is read first and the statement skipped when it already matches, which
264
+ # turns the common case into one cheap catalogue read.
265
+ #
266
+ # @param bincoll [TrueClass, FalseClass] whether to apply the binary collation
267
+ # @return [void]
268
+ #
257
269
  def self.apply_binary_collation(bincoll)
258
- if Utils.using_mysql?
259
- coll = "utf8mb4_general_ci"
260
- coll = "utf8mb4_bin" if bincoll
261
- begin
262
- ActiveRecord::Migration.execute("ALTER TABLE #{Tag.table_name} MODIFY name varchar(255) CHARACTER SET utf8mb4 COLLATE #{coll};")
263
- rescue => e
264
- puts "Trapping #{e.class}: collation parameter ignored while migrating for the first time."
265
- end
266
- end
270
+ return unless Utils.using_mysql?
271
+
272
+ collation = bincoll ? "utf8mb4_bin" : "utf8mb4_general_ci"
273
+
274
+ # Nothing to apply to yet -- this runs during the first migration, before
275
+ # the table exists.
276
+ return unless Utils.connection.table_exists?(Tag.table_name)
277
+ return if current_tag_name_collation == collation
278
+
279
+ ActiveRecord::Migration.execute(
280
+ "ALTER TABLE #{Tag.table_name} MODIFY name varchar(255) CHARACTER SET utf8mb4 COLLATE #{collation};"
281
+ )
282
+ end
283
+
284
+ ##
285
+ # The collation `tags.name` currently carries, or `nil` where it cannot be read.
286
+ #
287
+ # @return [String, NilClass]
288
+ #
289
+ def self.current_tag_name_collation
290
+ Utils.connection.columns(Tag.table_name).find { |column| column.name == "name" }&.collation
267
291
  end
268
292
  end
269
293
  setup
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: make_taggable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy