make_taggable 1.6.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: 990809ed5f39d8db0f974dbc5666dd040d5d8297423a3fc13b3af555ae5f1735
4
- data.tar.gz: 20a3ec9542d5089d7fa953540e6233ad22a0f8857ac21dcac664b9833cc2bdb1
3
+ metadata.gz: d9c3e4304ca2823d876d4ca13908a567b0d5be1521a2c413b39a98d8adbe976f
4
+ data.tar.gz: df708319ca01f12577082ada78b8a9093508aabd8013aeb5f02354c92ba29be7
5
5
  SHA512:
6
- metadata.gz: 44636fc042b034366c843c5ca909ea2ddbafbbd6d75d69976416b0fa956baffbb81e5beab0d746f87e1b5db320b3a42dc34b87fb632dcff3545d1e5f2461b502
7
- data.tar.gz: 113d18d8f21957b8ab11d3c044144afccc21c5c11726c3f757587fbf73d252cdb14913b0dd99168b7b8e725346e85f1afbcfc4392ce32605373732d0c7402a59
6
+ metadata.gz: f5e8acad4d9852e5ce2c7d44135126d2a1b70cff524c69d3d93531f271a4eb454608f2ca172945393cc4779e824201500b05dba24979854826725e146691bd4f
7
+ data.tar.gz: 044af9b759de0558e7adf4617ecfac8264421f7effc04db64556233215aa93357aaac951754378fdab996f1e3522a43eb3d363c9a66e978132a82886140fed84
data/CHANGELOG.md CHANGED
@@ -5,6 +5,78 @@ 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
+
33
+ ## [1.7.0] - 2026-08-23
34
+
35
+ ### Added
36
+
37
+ - `tag_class` names the class tags are read, written and returned as, so an application can give
38
+ tags its own validations, callbacks, associations and methods:
39
+
40
+ ```ruby
41
+ MakeTaggable.setup { |config| config.tag_class = "MyApp::Tag" }
42
+ ```
43
+
44
+ It takes a **String** and refuses a constant -- a model cannot be referenced while initializers
45
+ run. It must be set in an initializer, before models load, because `make_taggable` builds its
46
+ associations when a class body runs. See [docs/configuration.md](docs/configuration.md).
47
+
48
+ This is a global setting. Giving different contexts different vocabularies is a separate thing and
49
+ needs a `type` column -- see [docs/contexts.md](docs/contexts.md).
50
+
51
+ ### Changed
52
+
53
+ - The tag name uniqueness validation scopes itself by `type` when the tags table has that column, so
54
+ an application using Tag subclasses for per-context vocabularies can hold `"energy"` as a `Market`
55
+ and as a `Genre` while still rejecting two `Market("energy")`. Without the column nothing changes.
56
+
57
+ Previously the only way through was overriding `validates_name_uniqueness?` to `false`, which
58
+ turned the check off entirely and left genuine duplicates to surface as
59
+ `ActiveRecord::RecordNotUnique` from the database rather than as an error on the record. That hook
60
+ is still there for anyone who wants no check at all.
61
+
62
+ ### Fixed
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
+
74
+ - The recipe in `docs/contexts.md` for giving a context its own vocabulary did not work. It had you
75
+ add a `type` column and widen the unique index, then failed on the model validation with `Name has
76
+ already been taken` -- after a column had been added and a unique index rewritten on a production
77
+ table. The validation change above removes the obstacle, and the recipe is now written from a
78
+ tested run.
79
+
8
80
  ## [1.6.0] - 2026-08-23
9
81
 
10
82
  ### Fixed
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
@@ -26,6 +26,7 @@ MakeTaggable.force_lowercase = true
26
26
  | `remove_unused_tags` | `false` | Destroy a tag row when its last tagging goes |
27
27
  | `tags_counter` | `true` | Maintain the `taggings_count` counter cache |
28
28
  | `default_parser` | `MakeTaggable::DefaultParser` | Class used to parse tag input |
29
+ | `tag_class` | `"MakeTaggable::Tag"` | Name of the class tags are read and written as |
29
30
  | `delimiter` | `","` | Delimiter, or delimiters, separating tags |
30
31
  | `tags_table` | `:tags` | Table backing `MakeTaggable::Tag` |
31
32
  | `taggings_table` | `:taggings` | Table backing `MakeTaggable::Tagging` |
@@ -71,6 +72,51 @@ tagging, at the cost of `Tag.most_used` and `Tag.least_used`, both of which read
71
72
  Changing this on an existing application leaves the existing counts frozen at their current values
72
73
  rather than resetting them.
73
74
 
75
+ ### `tag_class`
76
+
77
+ The class tags are read, written and returned as. Give it a class of your own to add validations,
78
+ callbacks, associations or methods that apply everywhere tags are used.
79
+
80
+ ```ruby
81
+ # config/initializers/make_taggable.rb
82
+ MakeTaggable.setup do |config|
83
+ config.tag_class = "MyApp::Tag"
84
+ end
85
+ ```
86
+
87
+ ```ruby
88
+ # app/models/my_app/tag.rb
89
+ class MyApp::Tag < MakeTaggable::Tag
90
+ has_many :synonyms
91
+ validates :name, format: {with: /\A[a-z0-9-]+\z/}
92
+
93
+ def to_param = name
94
+ end
95
+ ```
96
+
97
+ After which every tag the library hands back is one of yours:
98
+
99
+ ```ruby
100
+ book.tags.first # => #<MyApp::Tag ...>
101
+ Book.all_tags.first # => #<MyApp::Tag ...>
102
+ Book.tag_counts_on(:genres).first # => #<MyApp::Tag ...>
103
+ book.tag_list = "sci-fi" # validated by MyApp::Tag on save
104
+ ```
105
+
106
+ **Two rules, both of which will bite if ignored.**
107
+
108
+ *Name the class, do not reference it.* The setting takes a String. A model constant cannot be
109
+ referenced while initializers run — Zeitwerk has not defined it yet, and eager loading in production
110
+ would try to resolve it before the class exists. Passing a constant raises `ArgumentError`.
111
+
112
+ *Set it in an initializer, before models load.* `make_taggable` builds its associations when a model
113
+ class body runs, so the class name has to be known by then. Setting it later — in a test, a console,
114
+ a request — leaves associations already built against the old class. Changing it needs a boot, not a
115
+ reload.
116
+
117
+ Your class should inherit from `MakeTaggable::Tag` and share the `tags` table. It is not a way to
118
+ give different contexts different vocabularies — see [contexts.md](contexts.md).
119
+
74
120
  ### `default_parser`
75
121
 
76
122
  See [parsers.md](parsers.md).
data/docs/contexts.md CHANGED
@@ -152,8 +152,13 @@ Dynamic contexts get none of the generated methods in the table above — there
152
152
  ## A separate vocabulary for one context
153
153
 
154
154
  Tags are shared across contexts and models by default: one `tags` row named `"ruby"` serves
155
- everything. To keep a context's tags separate, subclass `MakeTaggable::Tag` and override the hook
156
- that resolves names to records:
155
+ everything.
156
+
157
+ To change the class **globally** — to add validations or methods to every tag — set
158
+ [`tag_class`](configuration.md) rather than anything here. What follows is for giving one context a
159
+ vocabulary of its own, which is a different and larger thing.
160
+
161
+ Subclass `MakeTaggable::Tag` and override the hook that resolves names to records:
157
162
 
158
163
  ```ruby
159
164
  class Market < MakeTaggable::Tag
@@ -177,17 +182,38 @@ end
177
182
  This only genuinely separates the vocabularies if the tags table has a `type` column. Without one,
178
183
  Active Record has nowhere to record the subclass: rows created through `Market` are saved as plain
179
184
  tags, `Market.count` returns every tag in the table, and reloading a record gives you a
180
- `MakeTaggable::Tag` back. Add the column to get real separation:
185
+ `MakeTaggable::Tag` back.
186
+
187
+ Two schema changes give you real separation. The `type` column, and a replacement for the unique
188
+ index on `tags.name` — the shipped one stops two tags sharing a name at all, so a market and a genre
189
+ could never both be called "Energy":
181
190
 
182
191
  ```ruby
183
192
  class AddTypeToTags < ActiveRecord::Migration[7.2]
184
193
  def change
185
194
  add_column MakeTaggable.tags_table, :type, :string
186
- add_index MakeTaggable.tags_table, :type
195
+
196
+ remove_index MakeTaggable.tags_table, :name
197
+ add_index MakeTaggable.tags_table, [:name, :type], unique: true
187
198
  end
188
199
  end
189
200
  ```
190
201
 
191
- Note that the shipped migrations put a unique index on `tags.name`, so two tags cannot share a name
192
- even across subclasses. If a market and a genre both need to be called "Energy", widen that index to
193
- cover `[:name, :type]`.
202
+ The name uniqueness *validation* scopes itself by `type` as soon as the column exists, so a name can
203
+ repeat across subclasses while still being unique within one. Nothing else to configure.
204
+
205
+ With that in place the vocabularies are genuinely separate:
206
+
207
+ ```ruby
208
+ company = Company.create!(name: "Acme", market_list: "energy", genre_list: "energy")
209
+
210
+ MakeTaggable::Tag.pluck(:name, :type) # => [["energy", "Market"], ["energy", "Genre"]]
211
+ Market.count # => 1
212
+ Genre.count # => 1
213
+ company.markets.map(&:class) # => [Market]
214
+ company.market_list # => ["energy"]
215
+ Company.tagged_with("energy", on: :markets) # => [Acme]
216
+ ```
217
+
218
+ `tagged_with` without an `:on` still matches by name across every vocabulary, which is usually what
219
+ you want from an unscoped search — pass `:on` when you mean one of them.
@@ -24,7 +24,22 @@ module MakeTaggable
24
24
 
25
25
  ### VALIDATIONS:
26
26
  validates_presence_of :name
27
- validates_uniqueness_of :name, if: :validates_name_uniqueness?, case_sensitive: true
27
+ # Two declarations, one of which runs. A tags table with a `type` column is
28
+ # being used for single table inheritance -- a Tag subclass per vocabulary --
29
+ # and there a name is expected to repeat across subclasses: "energy" as a
30
+ # Market and as a Genre. Scoping the check keeps it meaningful within a
31
+ # subclass rather than making the whole thing something to switch off.
32
+ #
33
+ # The column is looked up per validation rather than when this class loads,
34
+ # because the class can load before the migration that adds it has run.
35
+ validates_uniqueness_of :name,
36
+ if: -> { validates_name_uniqueness? && !self.class.tag_type_column? },
37
+ case_sensitive: true
38
+
39
+ validates_uniqueness_of :name,
40
+ scope: :type,
41
+ if: -> { validates_name_uniqueness? && self.class.tag_type_column? },
42
+ case_sensitive: true
28
43
  validates_length_of :name, maximum: 255
29
44
 
30
45
  ##
@@ -38,6 +53,18 @@ module MakeTaggable
38
53
  true
39
54
  end
40
55
 
56
+ ##
57
+ # Whether the tags table carries a `type` column, and so is being used for single table
58
+ # inheritance.
59
+ #
60
+ # @return [TrueClass, FalseClass]
61
+ #
62
+ # @api private
63
+ #
64
+ def self.tag_type_column?
65
+ column_names.include?("type")
66
+ end
67
+
41
68
  ### SCOPES:
42
69
  scope :most_used, ->(limit = 20) { order("taggings_count desc").limit(limit) }
43
70
  scope :least_used, ->(limit = 20) { order("taggings_count asc").limit(limit) }
@@ -170,7 +197,11 @@ module MakeTaggable
170
197
  # unwinds only the failed insert. Without one the caller's transaction
171
198
  # is left in an aborted state and everything it had done is lost.
172
199
  transaction(requires_new: true) { create(name: tag_name) }.tap { |tag| existing_tags << tag }
173
- 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
174
205
  if (tries -= 1).positive?
175
206
  existing_tags = named_any(list).to_a
176
207
  retry
@@ -110,7 +110,7 @@ module MakeTaggable::Taggable
110
110
  tagging_scope = MakeTaggable::Tagging.select(
111
111
  "#{MakeTaggable::Tagging.table_name}.tag_id, #{last_applied_at_projection}"
112
112
  )
113
- tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
113
+ tag_scope = MakeTaggable.tag_model.select("#{MakeTaggable::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
114
114
 
115
115
  # Joins and conditions
116
116
  tagging_conditions(options).each { |condition| tagging_scope = tagging_scope.where(condition) }
@@ -152,7 +152,7 @@ module MakeTaggable::Taggable
152
152
  tagging_scope = MakeTaggable::Tagging.select(
153
153
  "#{MakeTaggable::Tagging.table_name}.tag_id, COUNT(#{MakeTaggable::Tagging.table_name}.tag_id) AS tags_count, #{last_applied_at_projection}"
154
154
  )
155
- tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*, #{MakeTaggable::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
155
+ tag_scope = MakeTaggable.tag_model.select("#{MakeTaggable::Tag.table_name}.*, #{MakeTaggable::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
156
156
 
157
157
  # Current model is STI descendant, so add type checking to the join condition
158
158
  unless descends_from_active_record?
@@ -54,7 +54,7 @@ module MakeTaggable::Taggable
54
54
  after_remove: :dirtify_tag_list
55
55
 
56
56
  has_many context_tags, -> { order(taggings_order) },
57
- class_name: "MakeTaggable::Tag",
57
+ class_name: MakeTaggable.tag_class,
58
58
  through: context_taggings,
59
59
  source: :tag
60
60
  end
@@ -512,13 +512,24 @@ module MakeTaggable::Taggable
512
512
  end
513
513
 
514
514
  ##
515
- # Returns all tags that are not owned of a given context
515
+ ##
516
+ # A context's unowned tags, in the order they were applied.
517
+ #
518
+ # The order is always applied, not only under `preserve_tag_order`. Without it the database
519
+ # returns rows in whatever order it likes -- insertion order on SQLite, planner-dependent on
520
+ # PostgreSQL -- so the same list could come back differently on different calls or adapters, and
521
+ # `tag_list_was` could report the original in an order that never existed.
522
+ #
523
+ # `preserve_tag_order` still decides whether reordering a list counts as a change and whether
524
+ # saving rewrites taggings to match; this only makes reading deterministic.
525
+ #
526
+ # @param context [Symbol, String] the tagging context
527
+ # @return [ActiveRecord::Relation]
528
+ #
516
529
  def tags_on(context)
517
- scope = base_tags.where(["#{MakeTaggable::Tagging.table_name}.context = ? AND #{MakeTaggable::Tagging.table_name}.tagger_id IS NULL", context.to_s])
518
- # when preserving tag order, return tags in created order
519
- # if we added the order to the association this would always apply
520
- scope = scope.order("#{MakeTaggable::Tagging.table_name}.id") if self.class.preserve_tag_order?
521
- scope
530
+ base_tags
531
+ .where(["#{MakeTaggable::Tagging.table_name}.context = ? AND #{MakeTaggable::Tagging.table_name}.tagger_id IS NULL", context.to_s])
532
+ .order("#{MakeTaggable::Tagging.table_name}.id")
522
533
  end
523
534
 
524
535
  ##
@@ -591,7 +602,7 @@ module MakeTaggable::Taggable
591
602
  ##
592
603
  # Find existing tags or create non-existing tags
593
604
  def load_tags(tag_list)
594
- MakeTaggable::Tag.find_or_create_all_with_like_by_name(tag_list)
605
+ MakeTaggable.tag_model.find_or_create_all_with_like_by_name(tag_list)
595
606
  end
596
607
 
597
608
  ##
@@ -686,10 +697,15 @@ module MakeTaggable::Taggable
686
697
  ##
687
698
  # Finds or creates the tag records for a list, given the context they are being applied in.
688
699
  #
689
- # Override it to keep a separate vocabulary for one context by returning tags from a
690
- # {MakeTaggable::Tag} subclass.
700
+ # Override it to resolve one context's names through a different class -- one with its own
701
+ # validations or callbacks, say.
702
+ #
703
+ # This routes creation only. Reading gives back whatever {MakeTaggable.tag_class} names, and
704
+ # without a `type` column on the tags table there is nothing to tell a subclass's rows apart, so
705
+ # the vocabularies are not actually separate. See `docs/contexts.md` for the column to add if
706
+ # that is what you are after, and {MakeTaggable.tag_class} for changing the class globally.
691
707
  #
692
- # @example A separate Tag subclass for one context
708
+ # @example Resolving one context's names through another class
693
709
  # class Company < ActiveRecord::Base
694
710
  # make_taggable :markets, :locations
695
711
  #
@@ -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?
@@ -82,7 +82,7 @@ module MakeTaggable
82
82
 
83
83
  class_eval do
84
84
  has_many :taggings, as: :taggable, dependent: :destroy, class_name: "::MakeTaggable::Tagging"
85
- has_many :base_tags, through: :taggings, source: :tag, class_name: "::MakeTaggable::Tag"
85
+ has_many :base_tags, through: :taggings, source: :tag, class_name: MakeTaggable.tag_class
86
86
 
87
87
  def self.taggable?
88
88
  true
@@ -46,7 +46,7 @@ module MakeTaggable
46
46
  )
47
47
 
48
48
  has_many :owned_tags, -> { distinct },
49
- class_name: "::MakeTaggable::Tag",
49
+ class_name: MakeTaggable.tag_class,
50
50
  source: :tag,
51
51
  through: :owned_taggings
52
52
  end
@@ -30,7 +30,7 @@ module MakeTaggable
30
30
 
31
31
  self.table_name = MakeTaggable.taggings_table
32
32
 
33
- belongs_to :tag, class_name: "::MakeTaggable::Tag", counter_cache: MakeTaggable.tags_counter
33
+ belongs_to :tag, class_name: MakeTaggable.tag_class, counter_cache: MakeTaggable.tags_counter
34
34
  belongs_to :taggable, polymorphic: true
35
35
 
36
36
  belongs_to :tagger, polymorphic: true, optional: true
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.6.0"
9
+ VERSION = "1.7.1"
10
10
  end
data/lib/make_taggable.rb CHANGED
@@ -127,6 +127,17 @@ module MakeTaggable
127
127
  delimiter.end_with?(" ") ? delimiter : "#{delimiter} "
128
128
  end
129
129
 
130
+ ##
131
+ # The class tags are read, written and returned as.
132
+ #
133
+ # Resolved on each call rather than memoised, so a reloaded class in development is picked up.
134
+ #
135
+ # @return [Class]
136
+ #
137
+ def self.tag_model
138
+ tag_class.constantize
139
+ end
140
+
130
141
  ##
131
142
  # The library's settings.
132
143
  #
@@ -168,7 +179,7 @@ module MakeTaggable
168
179
  :remove_unused_tags, :default_parser,
169
180
  :tags_counter, :tags_table,
170
181
  :taggings_table
171
- attr_reader :delimiter, :strict_case_match
182
+ attr_reader :delimiter, :strict_case_match, :tag_class
172
183
 
173
184
  ##
174
185
  # Builds the configuration with the library's defaults.
@@ -186,6 +197,29 @@ module MakeTaggable
186
197
  @force_binary_collation = false
187
198
  @tags_table = :tags
188
199
  @taggings_table = :taggings
200
+ @tag_class = "MakeTaggable::Tag"
201
+ end
202
+
203
+ ##
204
+ # Sets the class tags are read, written and returned as.
205
+ #
206
+ # Must be a String. A model constant cannot be referenced while initializers run -- Zeitwerk
207
+ # has not defined it yet, and eager loading in production would try to resolve it before the
208
+ # class exists.
209
+ #
210
+ # @param class_name [String] the name of a class inheriting from {MakeTaggable::Tag}
211
+ # @return [String]
212
+ # @raise [ArgumentError] when given anything but a String
213
+ #
214
+ def tag_class=(class_name)
215
+ unless class_name.is_a?(String)
216
+ raise ArgumentError,
217
+ "tag_class must be a String, got #{class_name.inspect}. " \
218
+ "Naming the class rather than the constant is what lets it be set in an initializer, " \
219
+ "before Zeitwerk has defined it."
220
+ end
221
+
222
+ @tag_class = class_name
189
223
  end
190
224
 
191
225
  ##
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.6.0
4
+ version: 1.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy
@@ -33,8 +33,6 @@ executables: []
33
33
  extensions: []
34
34
  extra_rdoc_files: []
35
35
  files:
36
- - AATO_ISSUE_TRIAGE.md
37
- - AATO_UPSTREAM_COMMIT_REVIEW.md
38
36
  - CHANGELOG.md
39
37
  - CODE_OF_CONDUCT.md
40
38
  - CONTRIBUTING.md
@@ -94,41 +92,122 @@ metadata:
94
92
  documentation_uri: https://github.com/MatthewKennedy/make_taggable/blob/master/docs
95
93
  rubygems_mfa_required: 'true'
96
94
  post_install_message: |
97
- Upgrading to MakeTaggable 1.0
95
+ Upgrading MakeTaggable
98
96
 
99
- 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.
100
99
 
101
- 1. Rename the declarations in your models:
100
+ --------------------------------------------------------------------------
101
+ 1.7.0
102
102
 
103
- acts_as_taggable -> make_taggable
104
- acts_as_taggable_on :skills -> make_taggable :skills
105
- acts_as_ordered_taggable -> make_ordered_taggable
106
- acts_as_ordered_taggable_on :skills -> make_ordered_taggable :skills
107
- acts_as_tagger -> make_tagger
103
+ Tag lists come back in a stable order
108
104
 
109
- Everything generated per context keeps its name: skill_list, skills,
110
- 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.
111
108
 
112
- 2. If you configure a delimiter containing a regular expression
113
- 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.
114
111
 
115
- MakeTaggable.delimiter = ['\|'] -> MakeTaggable.delimiter = ["|"]
112
+ New: a Tag class of your own
116
113
 
117
- 3. If you rely on `make_taggable` with no arguments adding no contexts,
118
- note that it now tags on :tags.
114
+ MakeTaggable.setup { |config| config.tag_class = "MyApp::Tag" }
119
115
 
120
- 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.
121
117
 
122
- Note for anyone upgrading past 1.0: a later migration adds a unique index
123
- preventing duplicate unowned taggings. If it fails, your taggings table
124
- already holds duplicates -- docs/database.md has a snippet to clear them.
118
+ --------------------------------------------------------------------------
119
+ 1.6.0
125
120
 
126
- Install any new migrations:
121
+ Eager loading tag lists actually works
127
122
 
128
- rails make_taggable_engine:install:migrations
129
- rails db:migrate
123
+ Book.includes(:tags).each { |book| book.tag_list }
130
124
 
131
- 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
132
211
  rdoc_options: []
133
212
  require_paths:
134
213
  - lib
data/AATO_ISSUE_TRIAGE.md DELETED
@@ -1,122 +0,0 @@
1
- # acts-as-taggable-on open issues — applicability to MakeTaggable
2
-
3
- All 66 open issues on `mbleigh/acts-as-taggable-on` (as of 2026-08-23) checked against
4
- `make_taggable` at 1.1.1. Everything marked **confirmed** was reproduced by running code
5
- against the gem's own SQLite harness (Ruby 4.0.1 / Active Record 8.1.3); everything marked
6
- **static** was read out of the source. Adapter-specific reports that need MySQL/PostgreSQL
7
- are listed separately as unverified.
8
-
9
- Verdict counts: 29 apply, 5 apply as feature gaps, 13 do not apply, 8 unverified,
10
- 11 documentation issues already covered.
11
-
12
- ---
13
-
14
- ## Applies — confirmed by reproduction
15
-
16
- ### Query builder
17
-
18
- | Upstream | Symptom in MakeTaggable | Evidence |
19
- |---|---|---|
20
- | #402, #993 | `tagged_with` returns a record once per matching tagging. A record tagged `"interesting"` in two contexts comes back twice. No `DISTINCT`, no context filter. | `OtherTaggableModel.tagged_with("interesting")` → 2 rows for 1 record |
21
- | #701 | `:exclude` ignores `:on`. `ExcludeTagsQuery#tags_not_in_list` builds no context predicate at all. | generated SQL contains no `context` clause |
22
- | #630 | `tagged_with([], exclude: true)` returns nothing. `core.rb` returns `none` on an empty list before the strategy is chosen, so "exclude nothing" excludes everything. | 0 rows, should be all |
23
- | #1094 | `tagged_with(tags, order_by_matching_tag_count: true)` raises `ActiveRecord::UnknownAttributeReference`. `AllTagsQuery#order_conditions` passes a raw subquery string to `.order`. | raises; the `any: true` path is fine because it wraps in `Arel.sql` |
24
- | #692, #1109, #530 | `.count` / `.size` on an `any: true` relation emits `COUNT("table".*)`, which is invalid SQL on SQLite, MySQL and PostgreSQL. Chaining two `tagged_with` calls compounds it. | `SQLite3::SQLException: near "*"` |
25
- | #936 | `AnyTagsQuery#build` calls `select(all_fields)`, so a caller's own `select` is appended rather than honoured. Also what breaks #395 (`merge` overwriting the SELECT). | `SELECT "taggable_models".*, "taggable_models"."id"` |
26
- | #915 + our own | `ExcludeTagsQuery#tags_not_in_list` hardcodes `taggable_arel_table[:id]` instead of the model's primary key. Any model with a non-`id` primary key raises on `exclude: true`. This is strictly worse than the upstream report. | `NonStandardIdTaggableModel.tagged_with(["k"], exclude: true)` → `StatementInvalid` |
27
- | #277, #387 | No way to order by `taggings.created_at`. `tagged_with` hides the taggings behind SHA aliases; `all_tags(order: "taggings.created_at desc")` builds a subquery that doesn't project the column. | `no such column: taggings.created_at` |
28
- | #293 | `tagged_with` still emits one `INNER JOIN` per tag in the default (all-tags) mode. | 12 tags → 12 joins |
29
- | #1028, #657 | `QueryBase#tag_match_type` wraps the column in `LOWER()` unconditionally. On PostgreSQL the operator is already `ILIKE`, so the `LOWER()` is redundant *and* defeats `index_tags_on_name`. Same cause as the #657 cache-miss report. | static + confirmed in generated SQL |
30
- | #328 | SQLite's `LOWER()` is ASCII-only, so `Tag.named_any` misses case variants of non-ASCII names. | `Tag.named_any(["ünicode"])` → 0 for a tag named `"Ünicode"` |
31
-
32
- ### Attribute / dirty tracking
33
-
34
- | Upstream | Symptom | Evidence |
35
- |---|---|---|
36
- | #1024, #1064, #1029 | `tag_list` is declared as an `attribute`, so it appears in `as_json` (triggering a tag query per record) but reads back `nil` from `attributes`. | 19 queries to serialize 3 records; `attributes["tag_list"] == nil` |
37
- | #1155 | `tag_ids = []` after `as_json` silently does nothing — the tags stay attached. | tags survive the assignment |
38
- | #373 | `tag_list.add(...)` / `.remove(...)` mutate the array in place without `attribute_will_change!`, so `tag_list_changed?` stays false. Only whole-list assignment is tracked. | `false` after `.add("sfw")` |
39
- | #1047 | `TagList#remove` compares raw objects, so `remove(:foo)` is a no-op while `remove("foo")` works. | list unchanged |
40
- | #1139 | `TagList` carries `@parser`, which holds a *Class*. `Psych.safe_dump` refuses it, so anything serialising a taggable to YAML (audited, ActiveJob args) blows up. | `Psych::DisallowedClass: Tried to dump unspecified class: Class` |
41
-
42
- ### Saving
43
-
44
- | Upstream | Symptom | Evidence |
45
- |---|---|---|
46
- | #1176 | `strict_loading` is violated on save. `save_tags` → `tagging_contexts` → `custom_contexts` lazily loads `taggings`. | `StrictLoadingViolationError` on saving a persisted record |
47
- | #1128 | The same path means every save — even a no-op — issues tagging queries. | 3 queries on a save with no changes |
48
- | #665 | A tag over 255 characters fails `Tag`'s length validation, `create` returns it unsaved, and the taggable then fails with a misleading `Validation failed: Tag can't be blank`. | raises `RecordInvalid` |
49
- | #508 | `Tag.find_or_create_all_with_like_by_name` uses non-bang `create`, so validation errors added by a `Tag` subclass are swallowed and surface later as the wrong error. | static |
50
- | #947 | `save_tags` and `save_owned_tags` create taggings in list order, so two concurrent saves touching the same tags can deadlock on the `taggings_count` counter cache. Sorting `new_tags` by id would fix it. | static |
51
- | #290 | `preserve_tag_order` is one `class_attribute` for the whole model, so `make_taggable` and `make_ordered_taggable` in the same class clobber each other — the last call wins for every context. | `make_taggable :skills` + `make_ordered_taggable :books` → `preserve_tag_order?` is `true` for `:skills` |
52
- | #1044 | A context whose name starts with a digit raises a **`SyntaxError`** while the class body loads (we generate `def 1category_taggings`). Upstream only got an invalid-ivar error, so ours fails harder. | `SyntaxError` from `has_many` |
53
-
54
- ### Ownership and caching
55
-
56
- | Upstream | Symptom | Evidence |
57
- |---|---|---|
58
- | #233 | `Tagger#tag` does not refresh `cached_<context>_list`. `save_cached_tag_list` only mirrors unowned lists, so a cached column silently drifts from `all_tags_list`. | `cached_tag_list` stayed `nil` while `all_tags_list == ["owned"]` |
59
- | #571 | `Tagger#tag` always parses `:with` through the default parser. There is no `parse: false`, so a tag legitimately containing a comma is split into two. | `with: ["a, b"]` → `["a", "b"]` |
60
-
61
- ### Configuration
62
-
63
- | Upstream | Symptom | Evidence |
64
- |---|---|---|
65
- | #781, #945 | `force_binary_collation=` still issues an `ALTER TABLE` every time it's called. Set in an initializer, that runs on every boot — including every Sidekiq/cron process — and takes a metadata lock on the tags table. | `apply_binary_collation` still calls `ActiveRecord::Migration.execute` |
66
- | #769 | `force_parameterize` maps tags through `String#parameterize`, which reduces a fully non-ASCII tag to the empty string and `clean!` then drops it. | `["日本語", "ok tag"]` → `["ok-tag"]` |
67
-
68
- ## Applies — feature gaps rather than bugs
69
-
70
- | Upstream | Gap |
71
- |---|---|
72
- | #91 | No eager-loading path for tag lists. `includes(:tags)` doesn't stop `tag_list` re-querying (13 queries for 5 records). |
73
- | #804 | `tagged_with(tags, on: [:skills, :interests])` raises `TypeError: can't quote Array`. Only one context per call. |
74
- | #783 | The `Tag` class is hardcoded. `find_or_create_tags_from_list_with_context` lets you create subclass rows, but the associations still return `MakeTaggable::Tag`. |
75
- | #909 | `all_tags` / `all_tag_counts` accept no scope on the taggable's own attributes (`assert_valid_keys` rejects `:scope`). |
76
- | #698 | The generated migration has no `type:` on the polymorphic references, so a UUID-keyed taggable needs the migration edited by hand. Not documented. |
77
-
78
- ## Does not apply
79
-
80
- | Upstream | Why |
81
- |---|---|
82
- | #908, #914 | `Model.create!(tags: [tag])` works — the tagging saves with the default context. |
83
- | #1151 | Repeated single-context `make_taggable` calls define `<context>_from` correctly; `Ownership.included` re-runs on every call. |
84
- | #576 | Adding a tag in a second context does not delete the first context's taggings. |
85
- | #867 | Chaining `tagged_with` with the same tag produces one join — Active Record dedupes the identical alias. |
86
- | #1033 | `tagged_with(..., exclude: true)` returns the same result on a relation as on the class. |
87
- | #946 | `remove_unused_tags` behaves as documented; re-tagging after a removal works. |
88
- | #1023 | Ordered taggable + owner works. Our `order` argument is a bare `taggings.id`, which Active Record accepts. |
89
- | #1099 | `upsert_all` on a taggable model works. |
90
- | #395 | The `merge` symptom is #936's `select(all_fields)`, already listed. Not separately actionable. |
91
- | #300 (part) | `find_related_*.blank?` works; only `.count` is broken (listed as #300/#907). |
92
- | #455, #603 | Caching is documented — `docs/caching.md`. |
93
- | #848 | The array form of the strong parameter is documented in `docs/getting-started.md`. |
94
- | #981 | Docs already use `rails make_taggable_engine:install:migrations`, not `rake`. |
95
- | #885 | `docs/ownership.md` builds owned lists from `locations_from(user)`, not `all_tags_list`, so the cascade the issue describes can't happen. |
96
- | #754 | `tagged_with` parsing its argument is documented on the method. |
97
-
98
- ## Unverified — needs a PostgreSQL or MySQL run
99
-
100
- | Upstream | What to check |
101
- |---|---|
102
- | #852 | `tag_counts_on` on a relation built with `includes(...).where(other_table: ...)` → "subquery has too many columns". Our `generate_tagging_scope_in_clause` does `except(:select).select(pkey)`, which may already fix it. |
103
- | #1026 | `find_related_*` groups by every column on PostgreSQL; a `json` column has no equality operator and breaks `GROUP BY`. `Related#group_columns` still does this. |
104
- | #1069 | Ambiguous column on `.count` with a joined scope. Did not reproduce on SQLite. |
105
- | #1100 | "no implicit conversion of nil into String" on update — no reproduction in the issue. |
106
- | #1103 | Ownership with `acts_as_tenant`: owned taggings created through `taggings.create!` and destroyed through a bare `Tagging.where(...)` may bypass the tenant scope. |
107
- | #810 | Ordering against `acts_as_nested_set` — depends on callback order in the host app. |
108
- | #657 | The PostgreSQL index-miss half of #1028; needs an `EXPLAIN` on a real table. |
109
- | #915 | The integer-vs-varchar join half (separate from the primary-key bug above). |
110
-
111
- ---
112
-
113
- ## Suggested order of work
114
-
115
- 1. **#1044** — a `SyntaxError` at class-load time. Cheapest fix (validate the context name, or reject it with a clear error) and the worst failure mode.
116
- 2. **#915/exclude** — `ExcludeTagsQuery` hardcoding `:id`. One-line fix, silently wrong today.
117
- 3. **#701, #630** — `:exclude` ignoring context and the empty-list short circuit. Both are wrong *answers*, not errors.
118
- 4. **#402/#993** — duplicate rows from `tagged_with`. Needs a decision on `DISTINCT` vs. a subquery.
119
- 5. **#692/#1109/#530, #936, #1094** — the `AnyTagsQuery` select and the `AllTagsQuery` order. `.count` not working on a documented query option is a hard edge.
120
- 6. **#1139, #1047, #373** — small, self-contained `TagList` fixes.
121
- 7. **#1176, #1128** — stop `save_tags` loading `taggings` when nothing was assigned.
122
- 8. **#1024/#1064/#1029, #1155** — the `attribute :tag_list` design. The largest change; worth its own discussion.
@@ -1,74 +0,0 @@
1
- # Upstream commits since the fork point — what's worth pulling
2
-
3
- ## Fork point
4
-
5
- `make_taggable`'s history starts at a squashed "Initial commit" (7698fe0, 2020-11-16) with no
6
- shared ancestry, so the base had to be recovered by matching blobs. The fork's initial tree is
7
- **upstream v6.5.0** (`6b38c652`, 2019-10-29) — 62 of 75 Ruby/Markdown files match that tree
8
- exactly, and the bundled `CHANGELOG.md` stops at the v6.5.0 release notes.
9
-
10
- Since then upstream has 93 commits (v7.0.0 → v13.0.0), of which **39 touch `lib/` or `db/`**.
11
- Everything below is that 39, reviewed one by one.
12
-
13
- ## The important negative result
14
-
15
- **Upstream has not fixed any of the 29 confirmed bugs from the issue triage.** I checked HEAD
16
- (`4d58c53`) directly:
17
-
18
- - `ExcludeTagsQuery#tags_not_in_list` still hardcodes `taggable_arel_table[:id]`
19
- - `AnyTagsQuery#build` still calls `select(all_fields)`
20
- - `AllTagsQuery#order_conditions` still passes a raw string to `.order` without `Arel.sql`
21
-
22
- So there is no shortcut: that backlog is ours to fix either way. What upstream *does* have that we
23
- don't is a handful of small correctness fixes and three features.
24
-
25
- ---
26
-
27
- ## Worth pulling — correctness
28
-
29
- | Upstream | What it fixes | Status here |
30
- |---|---|---|
31
- | **12f08be** (#1081, v10) | `find_or_create_all_with_like_by_name` issues a raw `ActiveRecord::Base.connection.execute "ROLLBACK"` when it hits `RecordNotUnique`. That breaks any enclosing transaction and ignores multiple-database setups. Upstream replaced it with `transaction(requires_new: true) { create(...) }`. | **We still have the raw ROLLBACK.** Highest-value pull on this list — it corrupts caller transactions, and our own spec suite runs inside a transaction. |
32
- | **426d960 + a0cadfb** | `remove_unused_tags` handling when the counter cache is off, and avoiding a needless `tag.reload`. | **Ours is worse than upstream's pre-fix state.** `MakeTaggable.remove_unused_tags` is gated on `&& MakeTaggable.tags_counter`, so with `tags_counter = false` the setting silently does nothing. Verified: orphan tag survives. This is upstream issue #946 arriving by a different route. |
33
- | **2a8acc1** (#1065) | `using_postgresql?` matches only `"PostgreSQL"`, so the PostGIS adapter falls through to the MySQL/generic path — `LIKE` instead of `ILIKE`, and the wrong `GROUP BY` strategy. | Absent. One-line fix: `%w[PostgreSQL PostGIS].include?(adapter_name)`. |
34
- | **38fb4d2 / b915ca8** | `Utils.connection` uses the model-level `.connection`, soft-deprecated in Rails 7.2 in favour of `lease_connection`. | Absent. Worth taking since our floor is already AR 7.2 — go straight to `lease_connection`, no fallback branch needed. Note: I did **not** observe an actual deprecation warning on AR 8.1.3, so this is hygiene, not breakage. |
35
- | **1df5ac3** | Upstream dropped four single-column indexes on `taggings` as redundant against the composite ones. | **Applies, and ours is worse.** Our migrations produce **12 indexes** on `taggings`. At least five are dead weight: `tag_id` (prefix of `taggings_idx`), `taggable_id` (prefix of `taggings_taggable_context_idx`), `taggable_type`, `tagger_id` (prefix of the tagger pair), and we create the tagger pair **in both column orders** (`index_taggings_on_tagger_id_and_tagger_type` *and* `index_taggings_on_tagger_type_and_tagger_id`, the latter from `t.references`). Every tagging insert pays for all of them. |
36
-
37
- ## Worth pulling — features
38
-
39
- | Upstream | Feature | Note |
40
- |---|---|---|
41
- | **2014fcc** (#1082, v10) | `wild: :prefix` / `wild: :suffix` in addition to `wild: true`. | Small and self-contained, and it partly answers issue #1028: a suffix match (`foo%`) can use a plain btree index, where `%foo%` never can. |
42
- | **52d7dae** (#1053, v9) | `all_tag_counts(id: [...])` accepts an array of taggable ids, not just one. | Lets a caller compute tag counts for a page of records in one query instead of N. Cheap to take. |
43
- | **b4eed9b + 8ba7fee** (v9/v10) | A `base_class` config so `Tag` and `Tagging` inherit from the host's `ApplicationRecord` instead of `::ActiveRecord::Base` — needed for horizontally sharded / multi-database apps. 8ba7fee then changed it to a **String** because Zeitwerk won't let you reference a model constant at initializer time. | If we take this, take both commits: the String form is the correct one. Also relevant to issue #1103 (ownership + tenancy). |
44
- | **7e696e3 + 4a7948e + e2d211b + 5d86cce** (v8) | A `tenant` column on `taggings`, `acts_as_taggable_tenant`, `Tag.for_tenant`, `Tagging.by_tenant`. | The largest item here — a migration plus API surface. Directly addresses issue #1103. I'd treat this as a "do we want it?" product decision rather than a pull; it overlaps with what `acts_as_tenant` already does in the host app. |
45
-
46
- ## Not worth pulling
47
-
48
- | Upstream | Why not |
49
- |---|---|
50
- | **47da503** (case-sensitivity third arg to `matches`) | **Already present.** Our `query_base.rb` passes `MakeTaggable.strict_case_match`. |
51
- | **b54771d** (`force_encoding('BINARY')` removal) | **Already present** — we fixed this independently in 1.0.0 and the CHANGELOG records it. |
52
- | **f18679a** (drop `mb_chars` / `unicode_downcase`) | **Already present** — our `Tag` uses `name.to_s.downcase`. |
53
- | **31f29c9** (caching always on) | This deletes the lazy `columns` interception that upstream themselves added in PR #911 to avoid clobbering a host's own `columns` override. Our `Cache::Columns` is the better design and we have a spec for it (`ColumnsOverrideModel`). Taking this would be a regression. |
54
- | **93fd6d2, a54cc54, bdb86da** (v11 `ActiveSupport::Concern` / Zeitwerk refactors) | Pure restructuring of code we have already restructured differently in 1.0.0. No behaviour change. |
55
- | **380c0bc** (combine migrations into one) | Upstream folded migrations 1–7 into a single idempotent `SetupActsAsTaggableOn`. Tempting, but our six-migration chain is already published and 1.1.0 just added migration 6 — collapsing them now would break `install:migrations` for existing installs for no functional gain. Take the *index trimming* from 1df5ac3 without the consolidation. |
56
- | **89a4d7f, 37bfebc, 4c49575, cfd6e06, 866c38f, 46c4e2d, f7bfad9, 69e6bff, b1d7651, 6fa6b55, 8548529, e0f859e, 6fbd9d1, b7122b9, 25266d6, 954e7ce** | Release commits, CI/docker chores, formatting, Ruby 2.7 / Rails 6.1 / Rails 8 compatibility we already exceed, and migration-syntax cleanups against migration files we don't share. |
57
-
58
- ---
59
-
60
- ## Recommendation
61
-
62
- Four small commits are worth taking more or less as-is, and they're cheap:
63
-
64
- 1. **12f08be** — the raw `ROLLBACK` (correctness, affects callers' transactions)
65
- 2. **remove_unused_tags with `tags_counter = false`** (our own regression, upstream-adjacent)
66
- 3. **2a8acc1** — PostGIS adapter detection
67
- 4. **1df5ac3-style index trim** — but sized to our 12-index reality, as a *new* migration 7 that drops the redundant ones rather than by editing migration 5
68
-
69
- Then **2014fcc** (`wild: :prefix`/`:suffix`) and **52d7dae** (array `:id`) as easy feature wins.
70
-
71
- `base_class` and the tenant feature are both real decisions rather than pulls — worth discussing
72
- before either lands.
73
-
74
- None of this changes the issue triage: the 29 confirmed bugs have no upstream fix to inherit.