make_taggable 1.1.1 → 1.2.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: 7d3a7c89a48d79f6d985b8290d88007368ecdf983789a7e4881662724a1060a9
4
- data.tar.gz: dda60e219b50e10b8948b6b9b260e703d8815f43bd200b1c434875144ae68071
3
+ metadata.gz: 7988c41c462a75321cf31cb13888a01d87a4dec964b13d2bb8c94876d7291d2a
4
+ data.tar.gz: 76c39347c15e44dd9bad00317aa4e27f4ef58aadd638507340a74fe6bd5bbbd3
5
5
  SHA512:
6
- metadata.gz: 7aa7e8b6c7190cbc8fbdcaf54fd5f49bf8f877c3161c7466e7df98eefd8f14eaa208391b47fa5a328db563d489d5e9a6f82b3bfd4b0ce770cdedbf3423f958bc
7
- data.tar.gz: 0e868727368e57e72fe627cbbc4b7a4dc2077e459d6ac9131732788fec26a88037bd7ed12cb86a35f3b8308b3df7fa238319c24628f39e698c073e7d0dd8fa68
6
+ metadata.gz: 89496e3b067d1c4707580b30b4ad1a1be0701e8aa03f1e7de30d2c229b4eec96afd82eec4675e89c32cf5e8f0b3ccf28d7afb0ffcf4a7ada5b9ef2169232dcc2
7
+ data.tar.gz: bbeef557e819249bc1d13668ec6437401dfcfbe3d995874535c86d5b86747ff46d5e0bb3383905343057c2f773c0658766eed9580f6173789e288571a516f0bb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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.2.0] - 2026-08-23
9
+
10
+ ### Fixed
11
+
12
+ - `Tag.find_or_create_all_with_like_by_name` recovered from a lost race for a tag name by issuing a
13
+ raw `ROLLBACK`. That statement is not scoped to the failed insert -- it discarded whatever
14
+ transaction was open on the connection, which is nearly always one the caller opened, and on a
15
+ multi-database application it targeted whichever connection `ActiveRecord::Base` held rather than
16
+ the one the tags are on. Each insert now takes a savepoint of its own.
17
+
18
+ - `remove_unused_tags` did nothing at all when `tags_counter` was off, because the check read the
19
+ counter cache. With the counter off it now asks the tag's taggings directly, at the cost of one
20
+ indexed lookup per destroyed tagging. The documentation said the setting required `tags_counter`;
21
+ it no longer does.
22
+
23
+ - `Utils.using_postgresql?` matched only the adapter named `PostgreSQL`, so a PostGIS application
24
+ took the MySQL path -- `LIKE` in place of `ILIKE`, which quietly made tag matching
25
+ case-sensitive, and the wrong grouping strategy in `all_tags_on` and `find_related_*`.
26
+
27
+ ### Changed
28
+
29
+ - A migration dropping five indexes from `taggings` that no query planner can reach: `tag_id`,
30
+ `taggable_id`, `taggable_type` and `tagger_id` on their own, each a leading column of an index
31
+ that remains, plus a second copy of the tagger pair in the opposite column order. Twelve indexes
32
+ become seven, and every one of them is maintained on insert.
33
+
34
+ Install it with `rails make_taggable_engine:install:migrations`. It is reversible -- see
35
+ [docs/database.md](docs/database.md).
36
+
8
37
  ## [1.1.1] - 2026-08-22
9
38
 
10
39
  ### Fixed
@@ -0,0 +1,38 @@
1
+ class RemoveRedundantTaggingIndexes < ActiveRecord::Migration[7.2]
2
+ # Migrations 2 and 5 between them put twelve indexes on the taggings table.
3
+ # Five of them earn nothing: a B-tree index already answers any query that
4
+ # filters on a leading subset of its columns, so an index on a single column
5
+ # is dead weight whenever another index starts with that same column.
6
+ #
7
+ # tag_id -> covered by taggings_idx and taggings_unowned_idx
8
+ # taggable_id -> covered by taggings_taggable_context_idx and taggings_idy
9
+ # taggable_type -> covered by index_taggings_on_taggable_type_and_taggable_id
10
+ # tagger_id -> covered by index_taggings_on_tagger_id_and_tagger_type
11
+ #
12
+ # The fifth is the tagger pair, which migration 2 and migration 5 each added
13
+ # in opposite column orders. One of the two is enough; the order kept is the
14
+ # one the library's own queries filter in, tagger_id first.
15
+ #
16
+ # Every index is maintained on insert, so this is a write-cost and a
17
+ # disk-footprint change, not a query-plan one. Nothing here is load-bearing:
18
+ # each dropped index is a prefix of one that remains.
19
+ REDUNDANT_COLUMNS = [
20
+ [:tag_id],
21
+ [:taggable_id],
22
+ [:taggable_type],
23
+ [:tagger_id],
24
+ [:tagger_type, :tagger_id]
25
+ ].freeze
26
+
27
+ def up
28
+ REDUNDANT_COLUMNS.each do |columns|
29
+ remove_index MakeTaggable.taggings_table, column: columns if index_exists?(MakeTaggable.taggings_table, columns)
30
+ end
31
+ end
32
+
33
+ def down
34
+ REDUNDANT_COLUMNS.each do |columns|
35
+ add_index MakeTaggable.taggings_table, columns unless index_exists?(MakeTaggable.taggings_table, columns)
36
+ end
37
+ end
38
+ end
@@ -51,14 +51,18 @@ The library avoids creating both, but data loaded another way can still contain
51
51
 
52
52
  ### `remove_unused_tags`
53
53
 
54
- When the last tagging referencing a tag is destroyed, the tag row is destroyed too. Requires
55
- `tags_counter`, since it reads the counter cache to decide.
54
+ When the last tagging referencing a tag is destroyed, the tag row is destroyed too.
55
+
56
+ This works whether or not `tags_counter` is on. With the counter cache the tag row already carries
57
+ the count and is only re-read; without it the tag's remaining taggings are queried directly, which
58
+ costs one extra query per destroyed tagging.
56
59
 
57
60
  ### `tags_counter`
58
61
 
59
62
  Keeps `tags.taggings_count` up to date. Turning it off avoids a write to the tags row on every
60
- tagging, at the cost of `Tag.most_used`, `Tag.least_used` and `remove_unused_tags`, all of which
61
- read that counter.
63
+ tagging, at the cost of `Tag.most_used` and `Tag.least_used`, both of which read that counter.
64
+
65
+ `remove_unused_tags` is unaffected -- it falls back to querying the tag's taggings.
62
66
 
63
67
  Changing this on an existing application leaves the existing counts frozen at their current values
64
68
  rather than resetting them.
data/docs/database.md CHANGED
@@ -26,13 +26,48 @@ Both table names are configurable — see [configuration.md](configuration.md).
26
26
 
27
27
  ## Indexes
28
28
 
29
- The shipped migrations index `taggings` heavily: the polymorphic references index themselves, and
30
- migration 5 adds standalone indexes on `taggable_id`, `tagger_id`, `taggable_type` and `context`,
31
- plus four composites.
29
+ Migrations 2 and 5 between them put twelve indexes on `taggings`. Migration 7 drops five of them,
30
+ leaving seven:
32
31
 
33
- That suits read-heavy tagging. If your application writes taggings in bulk, the write cost is worth
34
- looking at — every index is maintained on insert, and several of the standalone ones are prefixes of
35
- composites that already exist. Drop what your queries do not use.
32
+ | Index | Columns | Why it is there |
33
+ |---|---|---|
34
+ | `taggings_idx` | `tag_id, taggable_id, taggable_type, context, tagger_id, tagger_type` | Unique. Stops a tag being applied twice in the same context by the same tagger |
35
+ | `taggings_unowned_idx` | `tag_id, taggable_id, taggable_type, context` where `tagger_id IS NULL` | Unique, partial. See below. Not created on MySQL |
36
+ | `taggings_taggable_context_idx` | `taggable_id, taggable_type, context` | Reading one record's tags in one context |
37
+ | `taggings_idy` | `taggable_id, taggable_type, tagger_id, context` | Reading one record's owned tags |
38
+ | — | `taggable_type, taggable_id` | The polymorphic association lookup |
39
+ | — | `tagger_id, tagger_type` | `Tagging.owned_by`, and `tagged_with(owned_by:)` |
40
+ | — | `context` | Filtering taggings by context alone |
41
+
42
+ The five migration 7 removes were `tag_id`, `taggable_id`, `taggable_type` and `tagger_id` on their
43
+ own, plus a second copy of the tagger pair in the opposite column order. A B-tree index already
44
+ answers any query filtering on a leading subset of its columns, so a single-column index earns
45
+ nothing when another index starts with that same column — and every index is maintained on insert.
46
+
47
+ If you installed the migrations before version 1.2.0, running
48
+ `rails make_taggable_engine:install:migrations` again copies migration 7 across. It is reversible,
49
+ and nothing it drops is load-bearing: each one is a prefix of an index that remains.
50
+
51
+ ### Concurrent tag creation
52
+
53
+ `tags.name` carries a unique index, so two requests creating the same tag at the same moment leave
54
+ one of them holding an `ActiveRecord::RecordNotUnique`.
55
+
56
+ `Tag.find_or_create_all_with_like_by_name` -- which every tag list goes through -- absorbs that. It
57
+ re-reads the tags and retries, up to three times, and raises
58
+ {MakeTaggable::DuplicateTagError} if the name is still taken after that.
59
+
60
+ Each insert takes a savepoint of its own, so the failed insert is the only thing rolled back. If you
61
+ call this inside a transaction of your own, that transaction and everything written into it survive
62
+ the retry:
63
+
64
+ ```ruby
65
+ Order.transaction do
66
+ order.update!(state: "placed")
67
+ order.tag_list = "priority" # a race here rolls back nothing but the tag insert
68
+ order.save!
69
+ end
70
+ ```
36
71
 
37
72
  ### Duplicate unowned taggings
38
73
 
@@ -139,6 +139,9 @@ module MakeTaggable
139
139
  # Finds every tag in a list by name, creating those that do not exist yet.
140
140
  #
141
141
  # A competing write that takes a name first is retried up to three times before giving up.
142
+ # Each insert runs in a savepoint of its own, so a name lost to a race unwinds that insert
143
+ # alone -- an enclosing transaction the caller opened is left untouched, along with everything
144
+ # written into it.
142
145
  #
143
146
  # @param list [Array<String>] the tag names
144
147
  # @return [Array<MakeTaggable::Tag>] in the order the names were given
@@ -162,10 +165,13 @@ module MakeTaggable
162
165
  # Tags created earlier in this call have to stay visible to the names
163
166
  # that follow, or a list holding both "Ruby" and "ruby" resolves to two
164
167
  # rows even though the two names compare equal.
165
- create(name: tag_name).tap { |tag| existing_tags << tag }
168
+ #
169
+ # The insert gets a savepoint of its own so that a RecordNotUnique
170
+ # unwinds only the failed insert. Without one the caller's transaction
171
+ # is left in an aborted state and everything it had done is lost.
172
+ transaction(requires_new: true) { create(name: tag_name) }.tap { |tag| existing_tags << tag }
166
173
  rescue ActiveRecord::RecordNotUnique
167
174
  if (tries -= 1).positive?
168
- ActiveRecord::Base.connection.execute "ROLLBACK"
169
175
  existing_tags = named_any(list).to_a
170
176
  retry
171
177
  end
@@ -50,8 +50,21 @@ module MakeTaggable
50
50
 
51
51
  private
52
52
 
53
+ # Destroys the tag this tagging pointed at, if nothing else references it and the library is
54
+ # configured to clean up after itself. Runs after destroy.
55
+ #
56
+ # How "nothing else references it" is established depends on the counter cache. With
57
+ # `tags_counter` on, the count is already on the row and only needs re-reading. With it off
58
+ # there is no count to read, so the taggings are asked directly -- `none?` stops at the first
59
+ # row rather than counting them all.
60
+ #
61
+ # @return [void]
53
62
  def remove_unused_tags
54
- if MakeTaggable.remove_unused_tags && MakeTaggable.tags_counter && tag.reload.taggings_count.zero?
63
+ return unless MakeTaggable.remove_unused_tags
64
+
65
+ if MakeTaggable.tags_counter
66
+ tag.destroy if tag.reload.taggings_count.zero?
67
+ elsif tag.taggings.reload.none?
55
68
  tag.destroy
56
69
  end
57
70
  end
@@ -5,6 +5,17 @@ module MakeTaggable
5
5
  # Database differences the rest of the library needs to work around.
6
6
  #
7
7
  module Utils
8
+ ##
9
+ # Adapter names that are PostgreSQL as far as SQL generation is concerned.
10
+ #
11
+ # PostGIS is the PostgreSQL adapter with spatial types layered on top. It reports its own
12
+ # adapter name, so it has to be named here or the library treats a PostGIS application as
13
+ # though it were on MySQL -- `LIKE` in place of `ILIKE`, and the wrong grouping strategy.
14
+ #
15
+ # @return [Array<String>] frozen
16
+ #
17
+ POSTGRESQL_ADAPTER_NAMES = %w[PostgreSQL PostGIS].freeze
18
+
8
19
  class << self
9
20
  ##
10
21
  # The connection tags are read and written through.
@@ -18,10 +29,12 @@ module MakeTaggable
18
29
  ##
19
30
  # Whether tags are stored in PostgreSQL.
20
31
  #
32
+ # True for PostGIS as well, which is PostgreSQL underneath.
33
+ #
21
34
  # @return [TrueClass, FalseClass]
22
35
  #
23
36
  def using_postgresql?
24
- connection && connection.adapter_name == "PostgreSQL"
37
+ !!connection && POSTGRESQL_ADAPTER_NAMES.include?(connection.adapter_name)
25
38
  end
26
39
 
27
40
  ##
@@ -47,7 +60,7 @@ module MakeTaggable
47
60
  ##
48
61
  # The case-insensitive pattern operator for the current adapter.
49
62
  #
50
- # @return [String] `"ILIKE"` on PostgreSQL, otherwise `"LIKE"`
63
+ # @return [String] `"ILIKE"` on PostgreSQL and PostGIS, otherwise `"LIKE"`
51
64
  #
52
65
  def like_operator
53
66
  using_postgresql? ? "ILIKE" : "LIKE"
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.1.1"
9
+ VERSION = "1.2.0"
10
10
  end
data/lib/make_taggable.rb CHANGED
@@ -141,7 +141,8 @@ module MakeTaggable
141
141
  # Whether tag names are parameterized before they are saved.
142
142
  # @return [TrueClass, FalseClass] defaults to `false`
143
143
  # @!attribute [rw] remove_unused_tags
144
- # Whether a tag row is destroyed once its last tagging goes away. Requires `tags_counter`.
144
+ # Whether a tag row is destroyed once its last tagging goes away. Works with or without
145
+ # `tags_counter`; without it the check costs one extra query per destroyed tagging.
145
146
  # @return [TrueClass, FalseClass] defaults to `false`
146
147
  # @!attribute [rw] default_parser
147
148
  # The class used to turn tag input into a {MakeTaggable::TagList}.
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.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy
@@ -47,6 +47,7 @@ files:
47
47
  - db/migrate/4_add_index_to_tags.rb
48
48
  - db/migrate/5_add_index_to_taggings.rb
49
49
  - db/migrate/6_add_unowned_taggings_unique_index.rb
50
+ - db/migrate/7_remove_redundant_tagging_indexes.rb
50
51
  - docs/caching.md
51
52
  - docs/configuration.md
52
53
  - docs/contexts.md