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 +4 -4
- data/CHANGELOG.md +29 -0
- data/db/migrate/7_remove_redundant_tagging_indexes.rb +38 -0
- data/docs/configuration.md +8 -4
- data/docs/database.md +41 -6
- data/lib/make_taggable/tag.rb +8 -2
- data/lib/make_taggable/tagging.rb +14 -1
- data/lib/make_taggable/utils.rb +15 -2
- data/lib/make_taggable/version.rb +1 -1
- data/lib/make_taggable.rb +2 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7988c41c462a75321cf31cb13888a01d87a4dec964b13d2bb8c94876d7291d2a
|
|
4
|
+
data.tar.gz: 76c39347c15e44dd9bad00317aa4e27f4ef58aadd638507340a74fe6bd5bbbd3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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.
|
|
55
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
data/lib/make_taggable/tag.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/make_taggable/utils.rb
CHANGED
|
@@ -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
|
|
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"
|
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.
|
|
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.
|
|
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
|