make_taggable 1.6.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 +51 -0
- data/docs/configuration.md +46 -0
- data/docs/contexts.md +33 -7
- data/lib/make_taggable/tag.rb +28 -1
- data/lib/make_taggable/taggable/collection.rb +2 -2
- data/lib/make_taggable/taggable/core.rb +27 -11
- 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,57 @@ 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
|
+
|
|
8
59
|
## [1.6.0] - 2026-08-23
|
|
9
60
|
|
|
10
61
|
### Fixed
|
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) }
|
|
@@ -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
|
#
|
|
@@ -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.
|