make_taggable 1.1.1 → 1.2.1

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: 99cb64f83f0cef74cc21f3fc09a341e62db5a2248b8c88ef5d720de4b9446abb
4
+ data.tar.gz: c3c08be5a20df6100567523555be2394dc32665770f6c65226e0fe91b3be5652
5
5
  SHA512:
6
- metadata.gz: 7aa7e8b6c7190cbc8fbdcaf54fd5f49bf8f877c3161c7466e7df98eefd8f14eaa208391b47fa5a328db563d489d5e9a6f82b3bfd4b0ce770cdedbf3423f958bc
7
- data.tar.gz: 0e868727368e57e72fe627cbbc4b7a4dc2077e459d6ac9131732788fec26a88037bd7ed12cb86a35f3b8308b3df7fa238319c24628f39e698c073e7d0dd8fa68
6
+ metadata.gz: ad8701479ff9565786acebff64af05fe5aaa88eddbcfc660f88778e4ca885a0d26438677754cfa3072dd54e3eef6374ebed4f1e4de6a70fe911824050cb0b19e
7
+ data.tar.gz: 47804d66800ff4df491f3b7037ba5e82c131fbaf4c9eb8442a650ed6ecbf1772483d2ae2237c5437cb8ffefca831d107744a3ffacb2c517cb65a161063bc6d0e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,62 @@ 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.1] - 2026-08-23
9
+
10
+ ### Fixed
11
+
12
+ - `tagged_with(..., exclude: true)` raised on any model whose primary key is not `id`. The exclude
13
+ strategy built its `NOT IN` predicate against a hardcoded `id` column, where the other two
14
+ strategies already used the model's primary key.
15
+
16
+ - `tagged_with(..., on: <context>, exclude: true)` ignored the context entirely, gathering taggings
17
+ from every context, so a record tagged in one context was excluded from a query about another.
18
+
19
+ - `tagged_with([], exclude: true)` returned nothing rather than everything. Excluding no tags rules
20
+ nothing out, so the whole scope now stands. This restores the property that `tagged_with(list)`
21
+ and `tagged_with(list, exclude: true)` partition the scope between them for any list.
22
+
23
+ - A tag context whose name cannot become a Ruby method name -- one starting with a digit, say --
24
+ raised `SyntaxError` while the model was loading, from inside Active Record's association
25
+ builder. Since `SyntaxError` is not a `StandardError` it slipped past application rescues.
26
+ Contexts are now checked as they are declared and rejected with an `ArgumentError` naming the
27
+ context. Non-ASCII context names keep working.
28
+
29
+ ### Internal
30
+
31
+ - The suite's schema teardown no longer depends on the order `connection.tables` returns. MySQL
32
+ ignores `DROP TABLE ... CASCADE` for foreign keys, so it only worked because `taggings` happens
33
+ to sort before `tags`.
34
+
35
+ ## [1.2.0] - 2026-08-23
36
+
37
+ ### Fixed
38
+
39
+ - `Tag.find_or_create_all_with_like_by_name` recovered from a lost race for a tag name by issuing a
40
+ raw `ROLLBACK`. That statement is not scoped to the failed insert -- it discarded whatever
41
+ transaction was open on the connection, which is nearly always one the caller opened, and on a
42
+ multi-database application it targeted whichever connection `ActiveRecord::Base` held rather than
43
+ the one the tags are on. Each insert now takes a savepoint of its own.
44
+
45
+ - `remove_unused_tags` did nothing at all when `tags_counter` was off, because the check read the
46
+ counter cache. With the counter off it now asks the tag's taggings directly, at the cost of one
47
+ indexed lookup per destroyed tagging. The documentation said the setting required `tags_counter`;
48
+ it no longer does.
49
+
50
+ - `Utils.using_postgresql?` matched only the adapter named `PostgreSQL`, so a PostGIS application
51
+ took the MySQL path -- `LIKE` in place of `ILIKE`, which quietly made tag matching
52
+ case-sensitive, and the wrong grouping strategy in `all_tags_on` and `find_related_*`.
53
+
54
+ ### Changed
55
+
56
+ - A migration dropping five indexes from `taggings` that no query planner can reach: `tag_id`,
57
+ `taggable_id`, `taggable_type` and `tagger_id` on their own, each a leading column of an index
58
+ that remains, plus a second copy of the tagger pair in the opposite column order. Twelve indexes
59
+ become seven, and every one of them is maintained on insert.
60
+
61
+ Install it with `rails make_taggable_engine:install:migrations`. It is reversible -- see
62
+ [docs/database.md](docs/database.md).
63
+
8
64
  ## [1.1.1] - 2026-08-22
9
65
 
10
66
  ### 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/contexts.md CHANGED
@@ -40,6 +40,23 @@ user.skill_list_change # => [["jogging"], ["diving"]]
40
40
  user.will_save_change_to_skill_list?
41
41
  ```
42
42
 
43
+ ### Naming a context
44
+
45
+ Because the context becomes part of every name in the table above, it has to be usable as a Ruby
46
+ method and instance variable name. A context starting with a digit, or containing a hyphen or a
47
+ space, is rejected when the model declares it:
48
+
49
+ ```ruby
50
+ class Book < ActiveRecord::Base
51
+ make_taggable :"1categories"
52
+ end
53
+ # => ArgumentError: :"1categories" cannot be used as a tag context: make_taggable generates
54
+ # methods and instance variables from it, and "1categories_list" is not a valid Ruby name.
55
+ ```
56
+
57
+ Validity is decided by asking Ruby, not by a pattern, so anything that makes a legal method name is
58
+ allowed — non-ASCII context names included.
59
+
43
60
  ## Adding contexts later
44
61
 
45
62
  Calling `make_taggable` again adds contexts rather than replacing them, which is what lets a
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
 
data/docs/querying.md CHANGED
@@ -28,12 +28,20 @@ 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 |
31
+ | `:on` | Restrict to one context. Honoured by every option, `:exclude` included |
32
32
  | `:owned_by` | Restrict to tags applied by one tagger |
33
33
  | `:order_by_matching_tag_count` | With `:any`, order by how many tags matched, most first |
34
34
  | `:start_at` | Only tags applied after this time |
35
35
  | `:end_at` | Only tags applied before this time |
36
36
 
37
+ An empty tag list means "nothing matches" for the matching options and "nothing is ruled out" for
38
+ `:exclude`, so the two always partition the scope between them:
39
+
40
+ ```ruby
41
+ Book.tagged_with([]) # => none
42
+ Book.tagged_with([], exclude: true) # => every book
43
+ ```
44
+
37
45
  `:wild` combines with `:any` or `:exclude`:
38
46
 
39
47
  ```ruby
@@ -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
@@ -129,7 +129,8 @@ module MakeTaggable::Taggable
129
129
  # @option options [Symbol, String] :on only tags applied in this context
130
130
  # @option options [Time, Date] :start_at only tags applied after this time
131
131
  # @option options [Time, Date] :end_at only tags applied before this time
132
- # @return [ActiveRecord::Relation] empty when no tags are given
132
+ # @return [ActiveRecord::Relation] empty when no tags are given, except under `:exclude`,
133
+ # where excluding no tags leaves the whole scope standing
133
134
  #
134
135
  # @example Every tag
135
136
  # User.tagged_with(["awesome", "cool"])
@@ -144,7 +145,9 @@ module MakeTaggable::Taggable
144
145
  tag_list = MakeTaggable.default_parser.new(tags).parse
145
146
  options = options.dup
146
147
 
147
- return none if tag_list.empty?
148
+ # Asking for no tags matches nothing, but *excluding* no tags rules
149
+ # nothing out, so the whole scope stands.
150
+ return options[:exclude].present? ? all : none if tag_list.empty?
148
151
 
149
152
  ::MakeTaggable::Taggable::TaggedWithQuery.build(self, MakeTaggable::Tag, MakeTaggable::Tagging, tag_list, options)
150
153
  end
@@ -20,15 +20,21 @@ module MakeTaggable::Taggable::TaggedWithQuery
20
20
  private
21
21
 
22
22
  def tags_not_in_list
23
- taggable_arel_table[:id].not_in(
23
+ on_condition = tagging_arel_table[:tag_id].eq(tag_arel_table[:id])
24
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
25
+ .and(tags_match_type)
26
+
27
+ # Without this the subquery gathers taggings from every context, so a
28
+ # record tagged in one context is excluded from a query about another.
29
+ if options[:on].present?
30
+ on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
31
+ end
32
+
33
+ taggable_arel_table[taggable_model.primary_key].not_in(
24
34
  tagging_arel_table
25
35
  .project(tagging_arel_table[:taggable_id])
26
36
  .join(tag_arel_table)
27
- .on(
28
- tagging_arel_table[:tag_id].eq(tag_arel_table[:id])
29
- .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
30
- .and(tags_match_type)
31
- )
37
+ .on(on_condition)
32
38
  )
33
39
 
34
40
  # FIXME: missing time scope, this is also missing in the original implementation
@@ -69,6 +69,7 @@ module MakeTaggable
69
69
  #
70
70
  def taggable_on(preserve_tag_order, *tag_types)
71
71
  tag_types = tag_types.to_a.flatten.compact.map(&:to_sym)
72
+ tag_types.each { |tag_type| validate_tag_context!(tag_type) }
72
73
 
73
74
  if taggable?
74
75
  self.tag_types = (self.tag_types + tag_types).uniq
@@ -97,5 +98,31 @@ module MakeTaggable
97
98
  include Ownership
98
99
  include Related
99
100
  end
101
+
102
+ # Rejects a context that cannot become the methods and instance variables the
103
+ # library generates from it.
104
+ #
105
+ # A context is interpolated straight into generated source -- `#{context}_list`,
106
+ # `#{context}_taggings`, `@#{context}_list`. A name that is not a valid identifier
107
+ # produces source Ruby cannot parse, and the resulting SyntaxError descends from
108
+ # ScriptError rather than StandardError, so it slips past an application's own
109
+ # rescue and takes the boot down pointing at Active Record's association builder
110
+ # rather than at the offending declaration.
111
+ #
112
+ # Validity is decided by asking Ruby rather than by pattern, so a context is
113
+ # accepted on exactly the terms the generated code needs -- non-ASCII names
114
+ # included, since those make perfectly good method names.
115
+ #
116
+ # @param tag_type [Symbol] the context being declared
117
+ # @return [void]
118
+ # @raise [ArgumentError] when the context cannot become an identifier
119
+ def validate_tag_context!(tag_type)
120
+ Object.new.instance_variable_defined?(:"@#{tag_type}_list")
121
+ rescue NameError
122
+ raise ArgumentError,
123
+ "#{tag_type.inspect} cannot be used as a tag context: " \
124
+ "make_taggable generates methods and instance variables from it, and " \
125
+ "\"#{tag_type}_list\" is not a valid Ruby name."
126
+ end
100
127
  end
101
128
  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.1"
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.1
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