make_taggable 1.4.0 → 1.6.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: 990809ed5f39d8db0f974dbc5666dd040d5d8297423a3fc13b3af555ae5f1735
4
+ data.tar.gz: 20a3ec9542d5089d7fa953540e6233ad22a0f8857ac21dcac664b9833cc2bdb1
5
5
  SHA512:
6
- metadata.gz: a71b99737d5b1f09b1fe76ffa1b5710c50f6330d1222d107bdfd685c966f6c4681040a688d29df8427b5cf3e91e1d29c458d00085de110187272afb925393ebb
7
- data.tar.gz: 6ce73b0b7e0497f084080f1d4a774c974f2acb0c94ffec9d90bdbe8326f4a1e2f28721f5b4372c7d7b71ddbe0c567490faec4eb22cf884b9723fc39cddc579f0
6
+ metadata.gz: 44636fc042b034366c843c5ca909ea2ddbafbbd6d75d69976416b0fa956baffbb81e5beab0d746f87e1b5db320b3a42dc34b87fb632dcff3545d1e5f2461b502
7
+ data.tar.gz: 113d18d8f21957b8ab11d3c044144afccc21c5c11726c3f757587fbf73d252cdb14913b0dd99168b7b8e725346e85f1afbcfc4392ce32605373732d0c7402a59
data/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ 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.6.0] - 2026-08-23
9
+
10
+ ### Fixed
11
+
12
+ - `includes(:tags)` made reading tag lists **slower**: it cost two extra queries and saved none,
13
+ because `tag_list` always queried for a context's unowned tags rather than reading what had been
14
+ preloaded. Reading five records' lists went from 8 queries to 3, and the count no longer grows
15
+ with the collection. Records loaded without the preload are unaffected.
16
+
17
+ - `tag_counts_on` raised `sub-select returns N columns - expected 1` on a relation built with
18
+ `includes`. An eager load builds its own column list, which survived the `except(:select)` used to
19
+ reduce the scope to primary keys.
20
+
21
+ - `all_tags` and `all_tag_counts` could not be ordered by a `taggings` column --
22
+ `order: "taggings.created_at desc"` raised `no such column`. The derived table they join now
23
+ exposes the latest `created_at` for each tag, so ordering by it means "when this tag was last
24
+ applied".
25
+
26
+ ## [1.5.0] - 2026-08-23
27
+
28
+ ### Added
29
+
30
+ - `:on` accepts several contexts as well as one, so a query can search a subset:
31
+ `tagged_with("classic", on: [:genres, :moods], any: true)`. Passing an array previously raised
32
+ `TypeError` from inside Arel.
33
+
34
+ ### Fixed
35
+
36
+ - `force_parameterize` discarded a tag outright when the name had no ASCII in it. `parameterize`
37
+ reduces such a name to an empty string, which was then rejected as blank, so a user tagging in
38
+ Japanese, Greek, Hebrew, Arabic or Cyrillic saved successfully and got back fewer tags than they
39
+ typed. The parameterized form is now kept only where there is one.
40
+
41
+ - `force_binary_collation = true` ran an `ALTER TABLE` every time it was assigned, with no check for
42
+ whether the column already carried that collation. The documented home for it is an initializer,
43
+ which runs once per process, so a deployment issued a schema change against the `tags` table from
44
+ every web worker, background worker, console and rake task -- each one taking a metadata lock that
45
+ other queries queue behind. The current collation is now read first and the statement skipped when
46
+ it matches. The blanket `rescue`/`puts` around it is replaced by an explicit `table_exists?` check.
47
+
48
+ - On PostgreSQL, tag names were matched as `LOWER(name) ILIKE ...`. `ILIKE` already folds case, so
49
+ the `LOWER()` changed nothing while making the expression non-sargable -- no index on `tags.name`
50
+ could be used, including a trigram index built for wild searches. It is skipped on PostgreSQL and
51
+ kept on the adapters that need it.
52
+
53
+ - Taggings are created in tag id order. Each insert bumps the tag's counter cache, so two concurrent
54
+ saves touching the same tags took row locks in whatever order their lists happened to be in, which
55
+ invites deadlocks. Models using `make_ordered_taggable` are unaffected -- there the creation order
56
+ carries the meaning.
57
+
8
58
  ## [1.4.0] - 2026-08-23
9
59
 
10
60
  ### 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
 
@@ -162,6 +168,18 @@ MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
162
168
  query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
163
169
  scopes will be wrong.
164
170
 
171
+ ## Reading tag lists across a collection
172
+
173
+ Reading `<context>_list` on each of a set of records queries once per record. Eager load the tags to
174
+ avoid it:
175
+
176
+ ```ruby
177
+ Book.includes(:tags).each { |book| book.tag_list } # constant queries, whatever the count
178
+ ```
179
+
180
+ The lists are then read from the preload. Owned tags are still excluded from `tag_list`, as they are
181
+ without it.
182
+
165
183
  ## Grouping and ordering by tagging columns
166
184
 
167
185
  `tagged_with` does not join the taggings table, so a `taggings` column is not in scope on the
@@ -175,6 +193,23 @@ Book.tagged_with("sci-fi").joins(:taggings).order("taggings.created_at desc")
175
193
  Note that joining reintroduces one row per tagging, which is exactly what `tagged_with` avoids on
176
194
  its own — add `.distinct` if you want records back rather than matches.
177
195
 
196
+ ## Counting tags for a subset of records
197
+
198
+ `tag_counts_on` and `all_tags` apply to whatever scope they are called on, so narrowing the records
199
+ narrows the counts:
200
+
201
+ ```ruby
202
+ Book.where(published: true).tag_counts_on(:genres)
203
+ Book.joins(:author).where(authors: {country: "IE"}).tag_counts_on(:genres)
204
+ ```
205
+
206
+ `:order` accepts a `taggings` column, which orders by when a tag was last applied:
207
+
208
+ ```ruby
209
+ Book.all_tags(order: "taggings.created_at desc") # most recently used first
210
+ Book.all_tag_counts(order: "count desc", limit: 10)
211
+ ```
212
+
178
213
  ## Performance notes
179
214
 
180
215
  - `tagged_with` tests for each tag with an `EXISTS` subquery, one per tag, and joins nothing. That
@@ -183,6 +218,14 @@ its own — add `.distinct` if you want records back rather than matches.
183
218
  is still worth reaching for `any: true` where the semantics allow it.
184
219
  - Saving a taggable reads nothing from the taggings table unless a tag list was actually assigned,
185
220
  so a save that touches no tags costs no extra query and works under `strict_loading`.
221
+ - On PostgreSQL, tag names are matched with `ILIKE` against the column itself rather than through
222
+ `LOWER()`, so an index on `tags.name` can be used. A wild search is the case that most wants one:
223
+
224
+ ```sql
225
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
226
+ CREATE INDEX index_tags_on_name_trgm ON tags USING gin (name gin_trgm_ops);
227
+ ```
228
+
186
229
  - `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
187
230
  page of results and expensive across a whole table.
188
231
  - `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
@@ -107,7 +107,9 @@ module MakeTaggable::Taggable
107
107
  options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
108
108
 
109
109
  ## Generate scope:
110
- tagging_scope = MakeTaggable::Tagging.select("#{MakeTaggable::Tagging.table_name}.tag_id")
110
+ tagging_scope = MakeTaggable::Tagging.select(
111
+ "#{MakeTaggable::Tagging.table_name}.tag_id, #{last_applied_at_projection}"
112
+ )
111
113
  tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
112
114
 
113
115
  # Joins and conditions
@@ -147,7 +149,9 @@ module MakeTaggable::Taggable
147
149
  options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
148
150
 
149
151
  ## Generate scope:
150
- tagging_scope = MakeTaggable::Tagging.select("#{MakeTaggable::Tagging.table_name}.tag_id, COUNT(#{MakeTaggable::Tagging.table_name}.tag_id) AS tags_count")
152
+ tagging_scope = MakeTaggable::Tagging.select(
153
+ "#{MakeTaggable::Tagging.table_name}.tag_id, COUNT(#{MakeTaggable::Tagging.table_name}.tag_id) AS tags_count, #{last_applied_at_projection}"
154
+ )
151
155
  tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*, #{MakeTaggable::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
152
156
 
153
157
  # Current model is STI descendant, so add type checking to the join condition
@@ -200,12 +204,38 @@ module MakeTaggable::Taggable
200
204
  scoped_ids = pluck(table_name_pkey)
201
205
  tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN (?)", scoped_ids)
202
206
  else
203
- tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN(#{safe_to_sql(except(:select).select(table_name_pkey))})")
207
+ tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN(#{safe_to_sql(taggable_ids_scope(table_name_pkey))})")
204
208
  end
205
209
 
206
210
  tagging_scope
207
211
  end
208
212
 
213
+ # The current scope reduced to primary keys, for embedding in an IN clause.
214
+ #
215
+ # `except(:select)` is not enough on its own. An eager load builds its own column list rather
216
+ # than storing it in select_values, so it survives and the subquery comes back with a column
217
+ # per attribute of every table involved -- where the IN needs exactly one. Turning the eager
218
+ # load into a join keeps any condition on the joined table while selecting only the key.
219
+ def taggable_ids_scope(table_name_pkey)
220
+ scope = except(:select)
221
+ eager_loaded = scope.includes_values + scope.eager_load_values
222
+
223
+ scope = scope.except(:includes, :eager_load, :preload).left_joins(*eager_loaded) if eager_loaded.any?
224
+
225
+ scope.select(table_name_pkey)
226
+ end
227
+
228
+ # The tagging scope is embedded as a derived table aliased to the taggings table name, so
229
+ # `order: "taggings.created_at"` resolves against that rather than the real table -- which
230
+ # projected tag_id alone, and the column appeared not to exist.
231
+ #
232
+ # It groups by tag_id, so a tag may stand for many taggings and there is no single created_at
233
+ # to expose. The latest is the useful one: ordering by it means "when this tag was last
234
+ # applied".
235
+ def last_applied_at_projection
236
+ "MAX(#{MakeTaggable::Tagging.table_name}.created_at) AS created_at"
237
+ end
238
+
209
239
  def tagging_conditions(options)
210
240
  tagging_conditions = []
211
241
  tagging_conditions.push sanitize_sql(["#{MakeTaggable::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
@@ -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
@@ -393,7 +408,7 @@ module MakeTaggable::Taggable
393
408
  if cached_tag_list_on(context) && ensure_included_cache_methods! && self.class.caching_tag_list_on?(context)
394
409
  MakeTaggable.default_parser.new(cached_tag_list_on(context)).parse
395
410
  else
396
- MakeTaggable::TagList.new(tags_on(context).map(&:name))
411
+ MakeTaggable::TagList.new(unowned_tag_names_on(context))
397
412
  end
398
413
 
399
414
  # Note what was there the first time the list is built, before anything
@@ -442,14 +457,60 @@ 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
 
472
+ ##
473
+ # The names of a context's unowned tags, read from the preloaded taggings where they are
474
+ # available and queried where they are not.
475
+ #
476
+ # `includes(:tags)` loads the per-context taggings association as well as the tags themselves,
477
+ # and those taggings carry `tagger_id` -- which is what makes them usable here, since the list
478
+ # excludes owned tags and the tags association alone cannot say which are owned.
479
+ #
480
+ # @param context [Symbol, String] the tagging context
481
+ # @return [Array<String>]
482
+ #
483
+ # @api private
484
+ #
485
+ def unowned_tag_names_on(context)
486
+ preloaded = preloaded_taggings_on(context)
487
+ return tags_on(context).map(&:name) unless preloaded
488
+
489
+ preloaded.map { |tagging| tagging.tag.name }
490
+ end
491
+
492
+ ##
493
+ # A context's taggings if they are already in memory, otherwise `nil`.
494
+ #
495
+ # Only unowned taggings are returned, in tagging order where the model preserves it, so the
496
+ # result matches what {#tags_on} would have queried.
497
+ #
498
+ # @param context [Symbol, String] the tagging context
499
+ # @return [Array<MakeTaggable::Tagging>, NilClass]
500
+ #
501
+ # @api private
502
+ #
503
+ def preloaded_taggings_on(context)
504
+ name = :"#{context.to_s.singularize}_taggings"
505
+ return unless self.class.reflect_on_association(name)
506
+
507
+ association = association(name)
508
+ return unless association.loaded?
509
+
510
+ taggings = association.target.reject(&:tagger_id)
511
+ self.class.preserve_tag_order? ? taggings.sort_by(&:id) : taggings
512
+ end
513
+
453
514
  ##
454
515
  # Returns all tags that are not owned of a given context
455
516
  def tags_on(context)
@@ -587,7 +648,13 @@ module MakeTaggable::Taggable
587
648
  taggings.not_owned.by_context(context).where(tag_id: old_tags).destroy_all
588
649
  end
589
650
 
590
- # Create new taggings:
651
+ # Create new taggings, in a consistent order. Each insert bumps the tag's
652
+ # counter cache, so two concurrent saves touching the same tags would
653
+ # otherwise take row locks in whatever order their lists happened to be
654
+ # in, and deadlock. Ordering is skipped where the model asked for tag
655
+ # order to be preserved, since there the creation order is the point.
656
+ new_tags = new_tags.sort_by(&:id) unless self.class.preserve_tag_order?
657
+
591
658
  new_tags.each do |tag|
592
659
  taggings.create!(tag_id: tag.id, context: context.to_s, taggable: self)
593
660
  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.6.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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy