make_taggable 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/docs/configuration.md +46 -0
- data/docs/contexts.md +33 -7
- data/docs/querying.md +29 -0
- data/lib/make_taggable/tag.rb +28 -1
- data/lib/make_taggable/taggable/collection.rb +35 -5
- data/lib/make_taggable/taggable/core.rb +70 -12
- 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 +1 -3
- 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: '0899d63ba230419961175b79e863fe890723fd2a58bf26a53c9fd5c75c8ce382'
|
|
4
|
+
data.tar.gz: 7cd1bd0136a30e8c9a2c0136ce064b6d334f2e4a29e9c5bd357988b07f6091e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d64862b76207d13c1411825adf40705ec70ec78f23ac11faf58ddcbc35e76f2c698910b7a5828f1ee831ba344210bd637bf84bf44dafc4c4ced3fbc84b051ec6
|
|
7
|
+
data.tar.gz: f6a75d3805a9afa297753266351286a14d390fe5c96d8074234ded4ea6678c3e67f77b821eabb5c9db6e3775da9b02f9ac912de953b2c31bfe00819c6b91bdc0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,75 @@ 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.0] - 2026-08-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `tag_class` names the class tags are read, written and returned as, so an application can give
|
|
13
|
+
tags its own validations, callbacks, associations and methods:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
MakeTaggable.setup { |config| config.tag_class = "MyApp::Tag" }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It takes a **String** and refuses a constant -- a model cannot be referenced while initializers
|
|
20
|
+
run. It must be set in an initializer, before models load, because `make_taggable` builds its
|
|
21
|
+
associations when a class body runs. See [docs/configuration.md](docs/configuration.md).
|
|
22
|
+
|
|
23
|
+
This is a global setting. Giving different contexts different vocabularies is a separate thing and
|
|
24
|
+
needs a `type` column -- see [docs/contexts.md](docs/contexts.md).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- A tag list came back in whatever order the database chose. An `ORDER BY` was applied only under
|
|
29
|
+
`make_ordered_taggable`, so `record.tag_list` could return the same tags in different orders on
|
|
30
|
+
different calls or adapters, and `tag_list_was` could report an original the list had never been
|
|
31
|
+
in. Lists are now always ordered by when each tag was applied; `preserve_tag_order` keeps its
|
|
32
|
+
existing meaning for writes and for change detection.
|
|
33
|
+
|
|
34
|
+
- The documentation for `find_or_create_tags_from_list_with_context` claimed overriding it keeps a
|
|
35
|
+
separate vocabulary for one context. It does not: it routes creation only, and without a `type`
|
|
36
|
+
column on the tags table nothing distinguishes a subclass's rows.
|
|
37
|
+
|
|
38
|
+
## [Unreleased]
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- The tag name uniqueness validation scopes itself by `type` when the tags table has that column, so
|
|
43
|
+
an application using Tag subclasses for per-context vocabularies can hold `"energy"` as a `Market`
|
|
44
|
+
and as a `Genre` while still rejecting two `Market("energy")`. Without the column nothing changes.
|
|
45
|
+
|
|
46
|
+
Previously the only way through was overriding `validates_name_uniqueness?` to `false`, which
|
|
47
|
+
turned the check off entirely and left genuine duplicates to surface as
|
|
48
|
+
`ActiveRecord::RecordNotUnique` from the database rather than as an error on the record. That hook
|
|
49
|
+
is still there for anyone who wants no check at all.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- The recipe in `docs/contexts.md` for giving a context its own vocabulary did not work. It had you
|
|
54
|
+
add a `type` column and widen the unique index, then failed on the model validation with `Name has
|
|
55
|
+
already been taken` -- after a column had been added and a unique index rewritten on a production
|
|
56
|
+
table. The validation change above removes the obstacle, and the recipe is now written from a
|
|
57
|
+
tested run.
|
|
58
|
+
|
|
59
|
+
## [1.6.0] - 2026-08-23
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- `includes(:tags)` made reading tag lists **slower**: it cost two extra queries and saved none,
|
|
64
|
+
because `tag_list` always queried for a context's unowned tags rather than reading what had been
|
|
65
|
+
preloaded. Reading five records' lists went from 8 queries to 3, and the count no longer grows
|
|
66
|
+
with the collection. Records loaded without the preload are unaffected.
|
|
67
|
+
|
|
68
|
+
- `tag_counts_on` raised `sub-select returns N columns - expected 1` on a relation built with
|
|
69
|
+
`includes`. An eager load builds its own column list, which survived the `except(:select)` used to
|
|
70
|
+
reduce the scope to primary keys.
|
|
71
|
+
|
|
72
|
+
- `all_tags` and `all_tag_counts` could not be ordered by a `taggings` column --
|
|
73
|
+
`order: "taggings.created_at desc"` raised `no such column`. The derived table they join now
|
|
74
|
+
exposes the latest `created_at` for each tag, so ordering by it means "when this tag was last
|
|
75
|
+
applied".
|
|
76
|
+
|
|
8
77
|
## [1.5.0] - 2026-08-23
|
|
9
78
|
|
|
10
79
|
### Added
|
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/docs/querying.md
CHANGED
|
@@ -168,6 +168,18 @@ MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
|
|
|
168
168
|
query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
|
|
169
169
|
scopes will be wrong.
|
|
170
170
|
|
|
171
|
+
## Reading tag lists across a collection
|
|
172
|
+
|
|
173
|
+
Reading `<context>_list` on each of a set of records queries once per record. Eager load the tags to
|
|
174
|
+
avoid it:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
Book.includes(:tags).each { |book| book.tag_list } # constant queries, whatever the count
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The lists are then read from the preload. Owned tags are still excluded from `tag_list`, as they are
|
|
181
|
+
without it.
|
|
182
|
+
|
|
171
183
|
## Grouping and ordering by tagging columns
|
|
172
184
|
|
|
173
185
|
`tagged_with` does not join the taggings table, so a `taggings` column is not in scope on the
|
|
@@ -181,6 +193,23 @@ Book.tagged_with("sci-fi").joins(:taggings).order("taggings.created_at desc")
|
|
|
181
193
|
Note that joining reintroduces one row per tagging, which is exactly what `tagged_with` avoids on
|
|
182
194
|
its own — add `.distinct` if you want records back rather than matches.
|
|
183
195
|
|
|
196
|
+
## Counting tags for a subset of records
|
|
197
|
+
|
|
198
|
+
`tag_counts_on` and `all_tags` apply to whatever scope they are called on, so narrowing the records
|
|
199
|
+
narrows the counts:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
Book.where(published: true).tag_counts_on(:genres)
|
|
203
|
+
Book.joins(:author).where(authors: {country: "IE"}).tag_counts_on(:genres)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`:order` accepts a `taggings` column, which orders by when a tag was last applied:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
Book.all_tags(order: "taggings.created_at desc") # most recently used first
|
|
210
|
+
Book.all_tag_counts(order: "count desc", limit: 10)
|
|
211
|
+
```
|
|
212
|
+
|
|
184
213
|
## Performance notes
|
|
185
214
|
|
|
186
215
|
- `tagged_with` tests for each tag with an `EXISTS` subquery, one per tag, and joins nothing. That
|
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) }
|
|
@@ -107,8 +107,10 @@ module MakeTaggable::Taggable
|
|
|
107
107
|
options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
|
|
108
108
|
|
|
109
109
|
## Generate scope:
|
|
110
|
-
tagging_scope = MakeTaggable::Tagging.select(
|
|
111
|
-
|
|
110
|
+
tagging_scope = MakeTaggable::Tagging.select(
|
|
111
|
+
"#{MakeTaggable::Tagging.table_name}.tag_id, #{last_applied_at_projection}"
|
|
112
|
+
)
|
|
113
|
+
tag_scope = MakeTaggable.tag_model.select("#{MakeTaggable::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
|
|
112
114
|
|
|
113
115
|
# Joins and conditions
|
|
114
116
|
tagging_conditions(options).each { |condition| tagging_scope = tagging_scope.where(condition) }
|
|
@@ -147,8 +149,10 @@ module MakeTaggable::Taggable
|
|
|
147
149
|
options[:conditions] = sanitize_sql(options[:conditions]) if options[:conditions]
|
|
148
150
|
|
|
149
151
|
## Generate scope:
|
|
150
|
-
tagging_scope = MakeTaggable::Tagging.select(
|
|
151
|
-
|
|
152
|
+
tagging_scope = MakeTaggable::Tagging.select(
|
|
153
|
+
"#{MakeTaggable::Tagging.table_name}.tag_id, COUNT(#{MakeTaggable::Tagging.table_name}.tag_id) AS tags_count, #{last_applied_at_projection}"
|
|
154
|
+
)
|
|
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])
|
|
152
156
|
|
|
153
157
|
# Current model is STI descendant, so add type checking to the join condition
|
|
154
158
|
unless descends_from_active_record?
|
|
@@ -200,12 +204,38 @@ module MakeTaggable::Taggable
|
|
|
200
204
|
scoped_ids = pluck(table_name_pkey)
|
|
201
205
|
tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN (?)", scoped_ids)
|
|
202
206
|
else
|
|
203
|
-
tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN(#{safe_to_sql(
|
|
207
|
+
tagging_scope = tagging_scope.where("#{MakeTaggable::Tagging.table_name}.taggable_id IN(#{safe_to_sql(taggable_ids_scope(table_name_pkey))})")
|
|
204
208
|
end
|
|
205
209
|
|
|
206
210
|
tagging_scope
|
|
207
211
|
end
|
|
208
212
|
|
|
213
|
+
# The current scope reduced to primary keys, for embedding in an IN clause.
|
|
214
|
+
#
|
|
215
|
+
# `except(:select)` is not enough on its own. An eager load builds its own column list rather
|
|
216
|
+
# than storing it in select_values, so it survives and the subquery comes back with a column
|
|
217
|
+
# per attribute of every table involved -- where the IN needs exactly one. Turning the eager
|
|
218
|
+
# load into a join keeps any condition on the joined table while selecting only the key.
|
|
219
|
+
def taggable_ids_scope(table_name_pkey)
|
|
220
|
+
scope = except(:select)
|
|
221
|
+
eager_loaded = scope.includes_values + scope.eager_load_values
|
|
222
|
+
|
|
223
|
+
scope = scope.except(:includes, :eager_load, :preload).left_joins(*eager_loaded) if eager_loaded.any?
|
|
224
|
+
|
|
225
|
+
scope.select(table_name_pkey)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The tagging scope is embedded as a derived table aliased to the taggings table name, so
|
|
229
|
+
# `order: "taggings.created_at"` resolves against that rather than the real table -- which
|
|
230
|
+
# projected tag_id alone, and the column appeared not to exist.
|
|
231
|
+
#
|
|
232
|
+
# It groups by tag_id, so a tag may stand for many taggings and there is no single created_at
|
|
233
|
+
# to expose. The latest is the useful one: ordering by it means "when this tag was last
|
|
234
|
+
# applied".
|
|
235
|
+
def last_applied_at_projection
|
|
236
|
+
"MAX(#{MakeTaggable::Tagging.table_name}.created_at) AS created_at"
|
|
237
|
+
end
|
|
238
|
+
|
|
209
239
|
def tagging_conditions(options)
|
|
210
240
|
tagging_conditions = []
|
|
211
241
|
tagging_conditions.push sanitize_sql(["#{MakeTaggable::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
|
|
@@ -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
|
|
@@ -408,7 +408,7 @@ module MakeTaggable::Taggable
|
|
|
408
408
|
if cached_tag_list_on(context) && ensure_included_cache_methods! && self.class.caching_tag_list_on?(context)
|
|
409
409
|
MakeTaggable.default_parser.new(cached_tag_list_on(context)).parse
|
|
410
410
|
else
|
|
411
|
-
MakeTaggable::TagList.new(
|
|
411
|
+
MakeTaggable::TagList.new(unowned_tag_names_on(context))
|
|
412
412
|
end
|
|
413
413
|
|
|
414
414
|
# Note what was there the first time the list is built, before anything
|
|
@@ -470,13 +470,66 @@ module MakeTaggable::Taggable
|
|
|
470
470
|
end
|
|
471
471
|
|
|
472
472
|
##
|
|
473
|
-
#
|
|
473
|
+
# The names of a context's unowned tags, read from the preloaded taggings where they are
|
|
474
|
+
# available and queried where they are not.
|
|
475
|
+
#
|
|
476
|
+
# `includes(:tags)` loads the per-context taggings association as well as the tags themselves,
|
|
477
|
+
# and those taggings carry `tagger_id` -- which is what makes them usable here, since the list
|
|
478
|
+
# excludes owned tags and the tags association alone cannot say which are owned.
|
|
479
|
+
#
|
|
480
|
+
# @param context [Symbol, String] the tagging context
|
|
481
|
+
# @return [Array<String>]
|
|
482
|
+
#
|
|
483
|
+
# @api private
|
|
484
|
+
#
|
|
485
|
+
def unowned_tag_names_on(context)
|
|
486
|
+
preloaded = preloaded_taggings_on(context)
|
|
487
|
+
return tags_on(context).map(&:name) unless preloaded
|
|
488
|
+
|
|
489
|
+
preloaded.map { |tagging| tagging.tag.name }
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
##
|
|
493
|
+
# A context's taggings if they are already in memory, otherwise `nil`.
|
|
494
|
+
#
|
|
495
|
+
# Only unowned taggings are returned, in tagging order where the model preserves it, so the
|
|
496
|
+
# result matches what {#tags_on} would have queried.
|
|
497
|
+
#
|
|
498
|
+
# @param context [Symbol, String] the tagging context
|
|
499
|
+
# @return [Array<MakeTaggable::Tagging>, NilClass]
|
|
500
|
+
#
|
|
501
|
+
# @api private
|
|
502
|
+
#
|
|
503
|
+
def preloaded_taggings_on(context)
|
|
504
|
+
name = :"#{context.to_s.singularize}_taggings"
|
|
505
|
+
return unless self.class.reflect_on_association(name)
|
|
506
|
+
|
|
507
|
+
association = association(name)
|
|
508
|
+
return unless association.loaded?
|
|
509
|
+
|
|
510
|
+
taggings = association.target.reject(&:tagger_id)
|
|
511
|
+
self.class.preserve_tag_order? ? taggings.sort_by(&:id) : taggings
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
##
|
|
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
|
+
#
|
|
474
529
|
def tags_on(context)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
scope = scope.order("#{MakeTaggable::Tagging.table_name}.id") if self.class.preserve_tag_order?
|
|
479
|
-
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")
|
|
480
533
|
end
|
|
481
534
|
|
|
482
535
|
##
|
|
@@ -549,7 +602,7 @@ module MakeTaggable::Taggable
|
|
|
549
602
|
##
|
|
550
603
|
# Find existing tags or create non-existing tags
|
|
551
604
|
def load_tags(tag_list)
|
|
552
|
-
MakeTaggable
|
|
605
|
+
MakeTaggable.tag_model.find_or_create_all_with_like_by_name(tag_list)
|
|
553
606
|
end
|
|
554
607
|
|
|
555
608
|
##
|
|
@@ -644,10 +697,15 @@ module MakeTaggable::Taggable
|
|
|
644
697
|
##
|
|
645
698
|
# Finds or creates the tag records for a list, given the context they are being applied in.
|
|
646
699
|
#
|
|
647
|
-
# Override it to
|
|
648
|
-
#
|
|
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.
|
|
649
707
|
#
|
|
650
|
-
# @example
|
|
708
|
+
# @example Resolving one context's names through another class
|
|
651
709
|
# class Company < ActiveRecord::Base
|
|
652
710
|
# make_taggable :markets, :locations
|
|
653
711
|
#
|
|
@@ -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.0
|
|
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
|
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.
|