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 +4 -4
- data/CHANGELOG.md +72 -0
- data/CONTRIBUTING.md +23 -1
- data/UPGRADING.md +104 -23
- data/docs/configuration.md +46 -0
- data/docs/contexts.md +33 -7
- data/lib/make_taggable/tag.rb +33 -2
- data/lib/make_taggable/taggable/collection.rb +2 -2
- data/lib/make_taggable/taggable/core.rb +27 -11
- data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +0 -38
- data/lib/make_taggable/taggable/tagged_with_query.rb +7 -0
- data/lib/make_taggable/taggable.rb +1 -1
- data/lib/make_taggable/tagger.rb +1 -1
- data/lib/make_taggable/tagging.rb +1 -1
- data/lib/make_taggable/version.rb +1 -1
- data/lib/make_taggable.rb +35 -1
- metadata +105 -26
- data/AATO_ISSUE_TRIAGE.md +0 -122
- data/AATO_UPSTREAM_COMMIT_REVIEW.md +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9c3e4304ca2823d876d4ca13908a567b0d5be1521a2c413b39a98d8adbe976f
|
|
4
|
+
data.tar.gz: df708319ca01f12577082ada78b8a9093508aabd8013aeb5f02354c92ba29be7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
1
|
+
Upgrading MakeTaggable
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Only the changes that can ask something of you are listed. Everything else is
|
|
4
|
+
in CHANGELOG.md.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
--------------------------------------------------------------------------
|
|
7
|
+
1.7.0
|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
15
|
+
Nothing to change. If you were sorting a list defensively, you no longer
|
|
16
|
+
need to.
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
New: a Tag class of your own
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
note that it now tags on :tags.
|
|
20
|
+
MakeTaggable.setup { |config| config.tag_class = "MyApp::Tag" }
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
Name the class as a String, in an initializer. See docs/configuration.md.
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
already holds duplicates -- docs/database.md has a snippet to clear them.
|
|
24
|
+
--------------------------------------------------------------------------
|
|
25
|
+
1.6.0
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
Eager loading tag lists actually works
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
rails db:migrate
|
|
29
|
+
Book.includes(:tags).each { |book| book.tag_list }
|
|
34
30
|
|
|
35
|
-
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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.
|
|
156
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
data/lib/make_taggable/tag.rb
CHANGED
|
@@ -24,7 +24,22 @@ module MakeTaggable
|
|
|
24
24
|
|
|
25
25
|
### VALIDATIONS:
|
|
26
26
|
validates_presence_of :name
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
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
|
|
690
|
-
#
|
|
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
|
|
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:
|
|
85
|
+
has_many :base_tags, through: :taggings, source: :tag, class_name: MakeTaggable.tag_class
|
|
86
86
|
|
|
87
87
|
def self.taggable?
|
|
88
88
|
true
|
data/lib/make_taggable/tagger.rb
CHANGED
|
@@ -30,7 +30,7 @@ module MakeTaggable
|
|
|
30
30
|
|
|
31
31
|
self.table_name = MakeTaggable.taggings_table
|
|
32
32
|
|
|
33
|
-
belongs_to :tag, class_name:
|
|
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
|
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.
|
|
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
|
|
95
|
+
Upgrading MakeTaggable
|
|
98
96
|
|
|
99
|
-
|
|
97
|
+
Only the changes that can ask something of you are listed. Everything else is
|
|
98
|
+
in CHANGELOG.md.
|
|
100
99
|
|
|
101
|
-
|
|
100
|
+
--------------------------------------------------------------------------
|
|
101
|
+
1.7.0
|
|
102
102
|
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
109
|
+
Nothing to change. If you were sorting a list defensively, you no longer
|
|
110
|
+
need to.
|
|
114
111
|
|
|
115
|
-
|
|
112
|
+
New: a Tag class of your own
|
|
116
113
|
|
|
117
|
-
|
|
118
|
-
note that it now tags on :tags.
|
|
114
|
+
MakeTaggable.setup { |config| config.tag_class = "MyApp::Tag" }
|
|
119
115
|
|
|
120
|
-
|
|
116
|
+
Name the class as a String, in an initializer. See docs/configuration.md.
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
already holds duplicates -- docs/database.md has a snippet to clear them.
|
|
118
|
+
--------------------------------------------------------------------------
|
|
119
|
+
1.6.0
|
|
125
120
|
|
|
126
|
-
|
|
121
|
+
Eager loading tag lists actually works
|
|
127
122
|
|
|
128
|
-
|
|
129
|
-
rails db:migrate
|
|
123
|
+
Book.includes(:tags).each { |book| book.tag_list }
|
|
130
124
|
|
|
131
|
-
|
|
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.
|