make_taggable 1.7.0 → 1.7.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: '0899d63ba230419961175b79e863fe890723fd2a58bf26a53c9fd5c75c8ce382'
4
- data.tar.gz: 7cd1bd0136a30e8c9a2c0136ce064b6d334f2e4a29e9c5bd357988b07f6091e3
3
+ metadata.gz: d9c3e4304ca2823d876d4ca13908a567b0d5be1521a2c413b39a98d8adbe976f
4
+ data.tar.gz: df708319ca01f12577082ada78b8a9093508aabd8013aeb5f02354c92ba29be7
5
5
  SHA512:
6
- metadata.gz: d64862b76207d13c1411825adf40705ec70ec78f23ac11faf58ddcbc35e76f2c698910b7a5828f1ee831ba344210bd637bf84bf44dafc4c4ced3fbc84b051ec6
7
- data.tar.gz: f6a75d3805a9afa297753266351286a14d390fe5c96d8074234ded4ea6678c3e67f77b821eabb5c9db6e3775da9b02f9ac912de953b2c31bfe00819c6b91bdc0
6
+ metadata.gz: f5e8acad4d9852e5ce2c7d44135126d2a1b70cff524c69d3d93531f271a4eb454608f2ca172945393cc4779e824201500b05dba24979854826725e146691bd4f
7
+ data.tar.gz: 044af9b759de0558e7adf4617ecfac8264421f7effc04db64556233215aa93357aaac951754378fdab996f1e3522a43eb3d363c9a66e978132a82886140fed84
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ 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.7.1] - 2026-08-23
9
+
10
+ ### Fixed
11
+
12
+ - `tagged_with(..., exclude: true, match_all: true)` raised `HAVING clause on a non-aggregate query`.
13
+ The combination is now refused with an `ArgumentError`: `:match_all` selects records carrying only
14
+ the given tags, `:exclude` selects records carrying none of them, and no set of records satisfies
15
+ both.
16
+
17
+ - Tag creation retries on a deadlock as well as on a duplicate key. Under concurrent writes MySQL
18
+ reports one or the other depending on how two inserts of the same name interleave on the unique
19
+ index, and only the second was being retried -- so a busy application could see
20
+ `ActiveRecord::Deadlocked` escape from what is meant to be a transparent retry.
21
+
22
+ ### Internal
23
+
24
+ - Coverage is measured (`COVERAGE=1 bundle exec rspec`) with a floor enforced in CI. It found the
25
+ `:exclude` combination above on its first run.
26
+ - The concurrency example runs. It had been skipped since the fork and referenced a `Barrier` class
27
+ that did not exist, so concurrent tag creation was untested outright.
28
+ - `UPGRADING.md`, which ships as the gem's post-install message, covers the whole 1.x line rather
29
+ than stopping at 1.0.
30
+ - The 1.7.0 changelog entry was split in two by a stray `Unreleased` heading, so the per-context
31
+ vocabulary changes that shipped in it were filed as unreleased. Folded back into 1.7.0.
32
+
8
33
  ## [1.7.0] - 2026-08-23
9
34
 
10
35
  ### Added
@@ -23,20 +48,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
23
48
  This is a global setting. Giving different contexts different vocabularies is a separate thing and
24
49
  needs a `type` column -- see [docs/contexts.md](docs/contexts.md).
25
50
 
26
- ### Fixed
27
-
28
- - A tag list came back in whatever order the database chose. An `ORDER BY` was applied only under
29
- `make_ordered_taggable`, so `record.tag_list` could return the same tags in different orders on
30
- different calls or adapters, and `tag_list_was` could report an original the list had never been
31
- in. Lists are now always ordered by when each tag was applied; `preserve_tag_order` keeps its
32
- existing meaning for writes and for change detection.
33
-
34
- - The documentation for `find_or_create_tags_from_list_with_context` claimed overriding it keeps a
35
- separate vocabulary for one context. It does not: it routes creation only, and without a `type`
36
- column on the tags table nothing distinguishes a subclass's rows.
37
-
38
- ## [Unreleased]
39
-
40
51
  ### Changed
41
52
 
42
53
  - The tag name uniqueness validation scopes itself by `type` when the tags table has that column, so
@@ -50,6 +61,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
50
61
 
51
62
  ### Fixed
52
63
 
64
+ - A tag list came back in whatever order the database chose. An `ORDER BY` was applied only under
65
+ `make_ordered_taggable`, so `record.tag_list` could return the same tags in different orders on
66
+ different calls or adapters, and `tag_list_was` could report an original the list had never been
67
+ in. Lists are now always ordered by when each tag was applied; `preserve_tag_order` keeps its
68
+ existing meaning for writes and for change detection.
69
+
70
+ - The documentation for `find_or_create_tags_from_list_with_context` claimed overriding it keeps a
71
+ separate vocabulary for one context. It does not: it routes creation only, and without a `type`
72
+ column on the tags table nothing distinguishes a subclass's rows.
73
+
53
74
  - The recipe in `docs/contexts.md` for giving a context its own vocabulary did not work. It had you
54
75
  add a `type` column and widen the unique index, then failed on the model validation with `Name has
55
76
  already been taken` -- after a column had been added and a unique index rewritten on a production
data/CONTRIBUTING.md CHANGED
@@ -17,7 +17,9 @@ smallest model and code that reproduce the problem.
17
17
  5. Format: `bundle exec standardrb --fix`.
18
18
  6. Document any public API you added: `bundle exec yard stats --list-undoc` should report 100%.
19
19
  7. Add an entry to [CHANGELOG.md](CHANGELOG.md) under "unreleased".
20
- 8. Open a pull request explaining what changed and why.
20
+ 8. If it changes something an application would have to react to, add it to
21
+ [UPGRADING.md](UPGRADING.md) — that file is the gem's post-install message.
22
+ 9. Open a pull request explaining what changed and why.
21
23
 
22
24
  Keep commits small and well described, and link any relevant issues. Don't bump the version — that
23
25
  happens at release.
@@ -38,6 +40,26 @@ DATABASE_ADAPTER=postgresql DATABASE_URL=postgres://localhost/make_taggable_test
38
40
  DATABASE_ADAPTER=mysql2 DATABASE_URL=mysql2://root@127.0.0.1/make_taggable_test bundle exec rake
39
41
  ```
40
42
 
43
+ Worth doing before opening a pull request if you touched anything that generates SQL. Several bugs
44
+ in this gem only appeared on one adapter — SQLite in particular is forgiving enough to hide them,
45
+ and one of them passed on SQLite only because it treats an unresolvable double-quoted identifier as
46
+ a string literal.
47
+
48
+ The concurrency example does not run on SQLite. It needs an adapter that supports real concurrent
49
+ writes, so it is skipped there and runs on MySQL and PostgreSQL.
50
+
51
+ ## Coverage
52
+
53
+ ```shell
54
+ COVERAGE=1 bundle exec rspec
55
+ ```
56
+
57
+ The run fails if coverage drops below the floor set in `spec/spec_helper.rb`, and CI runs it on
58
+ every pull request. Raise the floor when coverage improves; never lower it to make a run pass.
59
+
60
+ Coverage is a way of finding untested paths, not a target. The last time it was measured it pointed
61
+ straight at an untested option combination that turned out to be broken.
62
+
41
63
  Across every supported Rails version:
42
64
 
43
65
  ```shell
data/UPGRADING.md CHANGED
@@ -1,35 +1,116 @@
1
- Upgrading to MakeTaggable 1.0
1
+ Upgrading MakeTaggable
2
2
 
3
- This release renames the declaration methods and changes how delimiters are escaped.
3
+ Only the changes that can ask something of you are listed. Everything else is
4
+ in CHANGELOG.md.
4
5
 
5
- 1. Rename the declarations in your models:
6
+ --------------------------------------------------------------------------
7
+ 1.7.0
6
8
 
7
- acts_as_taggable -> make_taggable
8
- acts_as_taggable_on :skills -> make_taggable :skills
9
- acts_as_ordered_taggable -> make_ordered_taggable
10
- acts_as_ordered_taggable_on :skills -> make_ordered_taggable :skills
11
- acts_as_tagger -> make_tagger
9
+ Tag lists come back in a stable order
12
10
 
13
- Everything generated per context keeps its name: skill_list, skills,
14
- skill_counts, top_skills, skills_from, find_related_skills.
11
+ A list is now ordered by when each tag was applied. Before, an order was
12
+ only applied under make_ordered_taggable, so the database chose -- and on
13
+ PostgreSQL that could differ between calls.
15
14
 
16
- 2. If you configure a delimiter containing a regular expression
17
- metacharacter, unescape it. Delimiters are now literal strings:
15
+ Nothing to change. If you were sorting a list defensively, you no longer
16
+ need to.
18
17
 
19
- MakeTaggable.delimiter = ['\|'] -> MakeTaggable.delimiter = ["|"]
18
+ New: a Tag class of your own
20
19
 
21
- 3. If you rely on `make_taggable` with no arguments adding no contexts,
22
- note that it now tags on :tags.
20
+ MakeTaggable.setup { |config| config.tag_class = "MyApp::Tag" }
23
21
 
24
- 4. Requirements are now Ruby 3.2 and Active Record 7.2 or newer.
22
+ Name the class as a String, in an initializer. See docs/configuration.md.
25
23
 
26
- Note for anyone upgrading past 1.0: a later migration adds a unique index
27
- preventing duplicate unowned taggings. If it fails, your taggings table
28
- already holds duplicates -- docs/database.md has a snippet to clear them.
24
+ --------------------------------------------------------------------------
25
+ 1.6.0
29
26
 
30
- Install any new migrations:
27
+ Eager loading tag lists actually works
31
28
 
32
- rails make_taggable_engine:install:migrations
33
- rails db:migrate
29
+ Book.includes(:tags).each { |book| book.tag_list }
34
30
 
35
- Full notes: https://github.com/MatthewKennedy/make_taggable/blob/master/CHANGELOG.md
31
+ used to cost a query per record on top of the preload. It no longer does.
32
+ Nothing to change.
33
+
34
+ --------------------------------------------------------------------------
35
+ 1.4.0 ACTION NEEDED
36
+
37
+ Tag lists are no longer in as_json
38
+
39
+ A tag list is not an Active Record attribute any more, so it is not in
40
+ attributes and not in as_json. This is what stops serialising a
41
+ collection querying once per record.
42
+
43
+ If an API response included a tag list, it will stop:
44
+
45
+ book.as_json # no "tag_list" key
46
+ book.as_json(methods: :tag_list) # ask for it
47
+
48
+ attributes["tag_list"] is gone too -- it always returned nil, while
49
+ tag_list returned the tags. Use tag_list.
50
+
51
+ Dirty tracking is unchanged: tag_list_changed?, _was, _change,
52
+ saved_change_to_tag_list? and changes all still work.
53
+
54
+ MakeTaggable::Taggable::TagListType is removed. It backed the attribute.
55
+
56
+ --------------------------------------------------------------------------
57
+ 1.3.0 ACTION NEEDED
58
+
59
+ tagged_with no longer joins the taggings table
60
+
61
+ It tests for each tag with an EXISTS subquery instead. That is what stops
62
+ a record being returned once per matching tagging -- a tag applied in two
63
+ contexts used to return the record twice, and .count disagreed with the
64
+ number of records.
65
+
66
+ A taggings column is no longer in scope on the relation:
67
+
68
+ Book.tagged_with("sci-fi").order("taggings.created_at") # was fine
69
+ Book.tagged_with("sci-fi").joins(:taggings).order("taggings.created_at")
70
+
71
+ Joining brings back one row per tagging, so add .distinct if you want
72
+ records rather than matches.
73
+
74
+ Five indexes dropped from taggings
75
+
76
+ Migration 7 removes single-column indexes on tag_id, taggable_id,
77
+ taggable_type and tagger_id, plus a duplicate of the tagger pair. Each is
78
+ a leading column of an index that remains, so no query plan changes --
79
+ but every index was maintained on insert.
80
+
81
+ It is not applied automatically:
82
+
83
+ rails make_taggable_engine:install:migrations
84
+
85
+ --------------------------------------------------------------------------
86
+ 1.2.0
87
+
88
+ A migration you may want
89
+
90
+ Migration 6 adds a partial unique index stopping duplicate unowned
91
+ taggings, which the existing index could not do because it spans the
92
+ nullable tagger columns. Install it the same way. MySQL has no partial
93
+ indexes, so it is a no-op there.
94
+
95
+ --------------------------------------------------------------------------
96
+ 1.0.0 ACTION NEEDED
97
+
98
+ The declaration methods were renamed
99
+
100
+ acts_as_taggable -> make_taggable
101
+ acts_as_taggable_on :skills -> make_taggable :skills
102
+ acts_as_ordered_taggable -> make_ordered_taggable
103
+ acts_as_ordered_taggable_on :skills -> make_ordered_taggable :skills
104
+
105
+ Delimiters are literal strings
106
+
107
+ A delimiter is no longer interpolated into a pattern unescaped, so it no
108
+ longer needs escaping by hand:
109
+
110
+ MakeTaggable.delimiter = '\|' -> MakeTaggable.delimiter = "|"
111
+
112
+ make_taggable with no arguments tags on :tags
113
+
114
+ It previously added no contexts at all, despite the documentation.
115
+
116
+ Minimum versions are Ruby 3.2 and Active Record 7.2
@@ -197,7 +197,11 @@ module MakeTaggable
197
197
  # unwinds only the failed insert. Without one the caller's transaction
198
198
  # is left in an aborted state and everything it had done is lost.
199
199
  transaction(requires_new: true) { create(name: tag_name) }.tap { |tag| existing_tags << tag }
200
- rescue ActiveRecord::RecordNotUnique
200
+ # A deadlock counts as losing the race, the same as a duplicate key.
201
+ # MySQL reports one or the other depending on how two inserts of the
202
+ # same name interleave on the unique index, and both mean the work
203
+ # should be re-read and retried rather than abandoned.
204
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked
201
205
  if (tries -= 1).positive?
202
206
  existing_tags = named_any(list).to_a
203
207
  retry
@@ -13,7 +13,6 @@ module MakeTaggable::Taggable::TaggedWithQuery
13
13
  def build
14
14
  taggable_model.joins(owning_to_tagger)
15
15
  .where(tags_not_in_list)
16
- .having(tags_that_matches_count)
17
16
  .readonly(false)
18
17
  end
19
18
 
@@ -61,44 +60,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
61
60
  .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
62
61
  )
63
62
 
64
- if options[:match_all].present?
65
- arel_join = arel_join
66
- .join(tagging_arel_table, Arel::Nodes::OuterJoin)
67
- .on(
68
- match_all_on_conditions
69
- )
70
- end
71
-
72
63
  arel_join.join_sources
73
64
  end
74
-
75
- def match_all_on_conditions
76
- on_condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
77
- .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
78
-
79
- if options[:start_at].present?
80
- on_condition = on_condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
81
- end
82
-
83
- if options[:end_at].present?
84
- on_condition = on_condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
85
- end
86
-
87
- if options[:on].present?
88
- on_condition = on_condition.and(context_predicate)
89
- end
90
-
91
- on_condition
92
- end
93
-
94
- def tags_that_matches_count
95
- return [] unless options[:match_all].present?
96
-
97
- taggable_model.find_by_sql(tag_arel_table.project(Arel.star.count).where(tags_match_type).to_sql)
98
-
99
- tagging_arel_table[:taggable_id].count.eq(
100
- tag_arel_table.project(Arel.star.count).where(tags_match_type)
101
- )
102
- end
103
65
  end
104
66
  end
@@ -22,6 +22,13 @@ module MakeTaggable::Taggable::TaggedWithQuery
22
22
  # @return [ActiveRecord::Relation]
23
23
  #
24
24
  def self.build(taggable_model, tag_model, tagging_model, tag_list, options)
25
+ if options[:exclude].present? && options[:match_all].present?
26
+ raise ArgumentError,
27
+ ":match_all and :exclude cannot be combined. :match_all selects records carrying only the " \
28
+ "given tags and nothing else, :exclude selects records carrying none of them, and there is " \
29
+ "no set of records that satisfies both."
30
+ end
31
+
25
32
  if options[:exclude].present?
26
33
  ExcludeTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build
27
34
  elsif options[:any].present?
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.7.0"
9
+ VERSION = "1.7.1"
10
10
  end
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.7.0
4
+ version: 1.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy
@@ -92,41 +92,122 @@ metadata:
92
92
  documentation_uri: https://github.com/MatthewKennedy/make_taggable/blob/master/docs
93
93
  rubygems_mfa_required: 'true'
94
94
  post_install_message: |
95
- Upgrading to MakeTaggable 1.0
95
+ Upgrading MakeTaggable
96
96
 
97
- This release renames the declaration methods and changes how delimiters are escaped.
97
+ Only the changes that can ask something of you are listed. Everything else is
98
+ in CHANGELOG.md.
98
99
 
99
- 1. Rename the declarations in your models:
100
+ --------------------------------------------------------------------------
101
+ 1.7.0
100
102
 
101
- acts_as_taggable -> make_taggable
102
- acts_as_taggable_on :skills -> make_taggable :skills
103
- acts_as_ordered_taggable -> make_ordered_taggable
104
- acts_as_ordered_taggable_on :skills -> make_ordered_taggable :skills
105
- acts_as_tagger -> make_tagger
103
+ Tag lists come back in a stable order
106
104
 
107
- Everything generated per context keeps its name: skill_list, skills,
108
- skill_counts, top_skills, skills_from, find_related_skills.
105
+ A list is now ordered by when each tag was applied. Before, an order was
106
+ only applied under make_ordered_taggable, so the database chose -- and on
107
+ PostgreSQL that could differ between calls.
109
108
 
110
- 2. If you configure a delimiter containing a regular expression
111
- metacharacter, unescape it. Delimiters are now literal strings:
109
+ Nothing to change. If you were sorting a list defensively, you no longer
110
+ need to.
112
111
 
113
- MakeTaggable.delimiter = ['\|'] -> MakeTaggable.delimiter = ["|"]
112
+ New: a Tag class of your own
114
113
 
115
- 3. If you rely on `make_taggable` with no arguments adding no contexts,
116
- note that it now tags on :tags.
114
+ MakeTaggable.setup { |config| config.tag_class = "MyApp::Tag" }
117
115
 
118
- 4. Requirements are now Ruby 3.2 and Active Record 7.2 or newer.
116
+ Name the class as a String, in an initializer. See docs/configuration.md.
119
117
 
120
- Note for anyone upgrading past 1.0: a later migration adds a unique index
121
- preventing duplicate unowned taggings. If it fails, your taggings table
122
- already holds duplicates -- docs/database.md has a snippet to clear them.
118
+ --------------------------------------------------------------------------
119
+ 1.6.0
123
120
 
124
- Install any new migrations:
121
+ Eager loading tag lists actually works
125
122
 
126
- rails make_taggable_engine:install:migrations
127
- rails db:migrate
123
+ Book.includes(:tags).each { |book| book.tag_list }
128
124
 
129
- Full notes: https://github.com/MatthewKennedy/make_taggable/blob/master/CHANGELOG.md
125
+ used to cost a query per record on top of the preload. It no longer does.
126
+ Nothing to change.
127
+
128
+ --------------------------------------------------------------------------
129
+ 1.4.0 ACTION NEEDED
130
+
131
+ Tag lists are no longer in as_json
132
+
133
+ A tag list is not an Active Record attribute any more, so it is not in
134
+ attributes and not in as_json. This is what stops serialising a
135
+ collection querying once per record.
136
+
137
+ If an API response included a tag list, it will stop:
138
+
139
+ book.as_json # no "tag_list" key
140
+ book.as_json(methods: :tag_list) # ask for it
141
+
142
+ attributes["tag_list"] is gone too -- it always returned nil, while
143
+ tag_list returned the tags. Use tag_list.
144
+
145
+ Dirty tracking is unchanged: tag_list_changed?, _was, _change,
146
+ saved_change_to_tag_list? and changes all still work.
147
+
148
+ MakeTaggable::Taggable::TagListType is removed. It backed the attribute.
149
+
150
+ --------------------------------------------------------------------------
151
+ 1.3.0 ACTION NEEDED
152
+
153
+ tagged_with no longer joins the taggings table
154
+
155
+ It tests for each tag with an EXISTS subquery instead. That is what stops
156
+ a record being returned once per matching tagging -- a tag applied in two
157
+ contexts used to return the record twice, and .count disagreed with the
158
+ number of records.
159
+
160
+ A taggings column is no longer in scope on the relation:
161
+
162
+ Book.tagged_with("sci-fi").order("taggings.created_at") # was fine
163
+ Book.tagged_with("sci-fi").joins(:taggings).order("taggings.created_at")
164
+
165
+ Joining brings back one row per tagging, so add .distinct if you want
166
+ records rather than matches.
167
+
168
+ Five indexes dropped from taggings
169
+
170
+ Migration 7 removes single-column indexes on tag_id, taggable_id,
171
+ taggable_type and tagger_id, plus a duplicate of the tagger pair. Each is
172
+ a leading column of an index that remains, so no query plan changes --
173
+ but every index was maintained on insert.
174
+
175
+ It is not applied automatically:
176
+
177
+ rails make_taggable_engine:install:migrations
178
+
179
+ --------------------------------------------------------------------------
180
+ 1.2.0
181
+
182
+ A migration you may want
183
+
184
+ Migration 6 adds a partial unique index stopping duplicate unowned
185
+ taggings, which the existing index could not do because it spans the
186
+ nullable tagger columns. Install it the same way. MySQL has no partial
187
+ indexes, so it is a no-op there.
188
+
189
+ --------------------------------------------------------------------------
190
+ 1.0.0 ACTION NEEDED
191
+
192
+ The declaration methods were renamed
193
+
194
+ acts_as_taggable -> make_taggable
195
+ acts_as_taggable_on :skills -> make_taggable :skills
196
+ acts_as_ordered_taggable -> make_ordered_taggable
197
+ acts_as_ordered_taggable_on :skills -> make_ordered_taggable :skills
198
+
199
+ Delimiters are literal strings
200
+
201
+ A delimiter is no longer interpolated into a pattern unescaped, so it no
202
+ longer needs escaping by hand:
203
+
204
+ MakeTaggable.delimiter = '\|' -> MakeTaggable.delimiter = "|"
205
+
206
+ make_taggable with no arguments tags on :tags
207
+
208
+ It previously added no contexts at all, despite the documentation.
209
+
210
+ Minimum versions are Ruby 3.2 and Active Record 7.2
130
211
  rdoc_options: []
131
212
  require_paths:
132
213
  - lib