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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d900a8ac37e0f88ead6c0516fd30ddee5e8cc11c893b7d64e85c090a8b59598
4
- data.tar.gz: b364eef565e3a171c56558f928360de5230c445b4b210e1aab8f536f9b998c70
3
+ metadata.gz: '0899d63ba230419961175b79e863fe890723fd2a58bf26a53c9fd5c75c8ce382'
4
+ data.tar.gz: 7cd1bd0136a30e8c9a2c0136ce064b6d334f2e4a29e9c5bd357988b07f6091e3
5
5
  SHA512:
6
- metadata.gz: 611fd4635e580ff5aaaee314600d2d66e7cdb70a2bd22d8988e3eb541c3d632fc25625e485880821beeec8f0bfb71677242f3d0d14ba96e98e0dd4d15038e0ae
7
- data.tar.gz: b1713ea6a65eb7a8e45dfb0d4b8d41c9d72334cfb8de93ef2c1e60a21a6a4e04758acdcb7761a117b8b275f0aecb3e98d78258a8c7f8389f433836353e676958
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
@@ -26,6 +26,7 @@ MakeTaggable.force_lowercase = true
26
26
  | `remove_unused_tags` | `false` | Destroy a tag row when its last tagging goes |
27
27
  | `tags_counter` | `true` | Maintain the `taggings_count` counter cache |
28
28
  | `default_parser` | `MakeTaggable::DefaultParser` | Class used to parse tag input |
29
+ | `tag_class` | `"MakeTaggable::Tag"` | Name of the class tags are read and written as |
29
30
  | `delimiter` | `","` | Delimiter, or delimiters, separating tags |
30
31
  | `tags_table` | `:tags` | Table backing `MakeTaggable::Tag` |
31
32
  | `taggings_table` | `:taggings` | Table backing `MakeTaggable::Tagging` |
@@ -71,6 +72,51 @@ tagging, at the cost of `Tag.most_used` and `Tag.least_used`, both of which read
71
72
  Changing this on an existing application leaves the existing counts frozen at their current values
72
73
  rather than resetting them.
73
74
 
75
+ ### `tag_class`
76
+
77
+ The class tags are read, written and returned as. Give it a class of your own to add validations,
78
+ callbacks, associations or methods that apply everywhere tags are used.
79
+
80
+ ```ruby
81
+ # config/initializers/make_taggable.rb
82
+ MakeTaggable.setup do |config|
83
+ config.tag_class = "MyApp::Tag"
84
+ end
85
+ ```
86
+
87
+ ```ruby
88
+ # app/models/my_app/tag.rb
89
+ class MyApp::Tag < MakeTaggable::Tag
90
+ has_many :synonyms
91
+ validates :name, format: {with: /\A[a-z0-9-]+\z/}
92
+
93
+ def to_param = name
94
+ end
95
+ ```
96
+
97
+ After which every tag the library hands back is one of yours:
98
+
99
+ ```ruby
100
+ book.tags.first # => #<MyApp::Tag ...>
101
+ Book.all_tags.first # => #<MyApp::Tag ...>
102
+ Book.tag_counts_on(:genres).first # => #<MyApp::Tag ...>
103
+ book.tag_list = "sci-fi" # validated by MyApp::Tag on save
104
+ ```
105
+
106
+ **Two rules, both of which will bite if ignored.**
107
+
108
+ *Name the class, do not reference it.* The setting takes a String. A model constant cannot be
109
+ referenced while initializers run — Zeitwerk has not defined it yet, and eager loading in production
110
+ would try to resolve it before the class exists. Passing a constant raises `ArgumentError`.
111
+
112
+ *Set it in an initializer, before models load.* `make_taggable` builds its associations when a model
113
+ class body runs, so the class name has to be known by then. Setting it later — in a test, a console,
114
+ a request — leaves associations already built against the old class. Changing it needs a boot, not a
115
+ reload.
116
+
117
+ Your class should inherit from `MakeTaggable::Tag` and share the `tags` table. It is not a way to
118
+ give different contexts different vocabularies — see [contexts.md](contexts.md).
119
+
74
120
  ### `default_parser`
75
121
 
76
122
  See [parsers.md](parsers.md).
data/docs/contexts.md CHANGED
@@ -152,8 +152,13 @@ Dynamic contexts get none of the generated methods in the table above — there
152
152
  ## A separate vocabulary for one context
153
153
 
154
154
  Tags are shared across contexts and models by default: one `tags` row named `"ruby"` serves
155
- everything. To keep a context's tags separate, subclass `MakeTaggable::Tag` and override the hook
156
- that resolves names to records:
155
+ everything.
156
+
157
+ To change the class **globally** — to add validations or methods to every tag — set
158
+ [`tag_class`](configuration.md) rather than anything here. What follows is for giving one context a
159
+ vocabulary of its own, which is a different and larger thing.
160
+
161
+ Subclass `MakeTaggable::Tag` and override the hook that resolves names to records:
157
162
 
158
163
  ```ruby
159
164
  class Market < MakeTaggable::Tag
@@ -177,17 +182,38 @@ end
177
182
  This only genuinely separates the vocabularies if the tags table has a `type` column. Without one,
178
183
  Active Record has nowhere to record the subclass: rows created through `Market` are saved as plain
179
184
  tags, `Market.count` returns every tag in the table, and reloading a record gives you a
180
- `MakeTaggable::Tag` back. Add the column to get real separation:
185
+ `MakeTaggable::Tag` back.
186
+
187
+ Two schema changes give you real separation. The `type` column, and a replacement for the unique
188
+ index on `tags.name` — the shipped one stops two tags sharing a name at all, so a market and a genre
189
+ could never both be called "Energy":
181
190
 
182
191
  ```ruby
183
192
  class AddTypeToTags < ActiveRecord::Migration[7.2]
184
193
  def change
185
194
  add_column MakeTaggable.tags_table, :type, :string
186
- add_index MakeTaggable.tags_table, :type
195
+
196
+ remove_index MakeTaggable.tags_table, :name
197
+ add_index MakeTaggable.tags_table, [:name, :type], unique: true
187
198
  end
188
199
  end
189
200
  ```
190
201
 
191
- Note that the shipped migrations put a unique index on `tags.name`, so two tags cannot share a name
192
- even across subclasses. If a market and a genre both need to be called "Energy", widen that index to
193
- cover `[:name, :type]`.
202
+ The name uniqueness *validation* scopes itself by `type` as soon as the column exists, so a name can
203
+ repeat across subclasses while still being unique within one. Nothing else to configure.
204
+
205
+ With that in place the vocabularies are genuinely separate:
206
+
207
+ ```ruby
208
+ company = Company.create!(name: "Acme", market_list: "energy", genre_list: "energy")
209
+
210
+ MakeTaggable::Tag.pluck(:name, :type) # => [["energy", "Market"], ["energy", "Genre"]]
211
+ Market.count # => 1
212
+ Genre.count # => 1
213
+ company.markets.map(&:class) # => [Market]
214
+ company.market_list # => ["energy"]
215
+ Company.tagged_with("energy", on: :markets) # => [Acme]
216
+ ```
217
+
218
+ `tagged_with` without an `:on` still matches by name across every vocabulary, which is usually what
219
+ you want from an unscoped search — pass `:on` when you mean one of them.
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
@@ -24,7 +24,22 @@ module MakeTaggable
24
24
 
25
25
  ### VALIDATIONS:
26
26
  validates_presence_of :name
27
- validates_uniqueness_of :name, if: :validates_name_uniqueness?, case_sensitive: true
27
+ # Two declarations, one of which runs. A tags table with a `type` column is
28
+ # being used for single table inheritance -- a Tag subclass per vocabulary --
29
+ # and there a name is expected to repeat across subclasses: "energy" as a
30
+ # Market and as a Genre. Scoping the check keeps it meaningful within a
31
+ # subclass rather than making the whole thing something to switch off.
32
+ #
33
+ # The column is looked up per validation rather than when this class loads,
34
+ # because the class can load before the migration that adds it has run.
35
+ validates_uniqueness_of :name,
36
+ if: -> { validates_name_uniqueness? && !self.class.tag_type_column? },
37
+ case_sensitive: true
38
+
39
+ validates_uniqueness_of :name,
40
+ scope: :type,
41
+ if: -> { validates_name_uniqueness? && self.class.tag_type_column? },
42
+ case_sensitive: true
28
43
  validates_length_of :name, maximum: 255
29
44
 
30
45
  ##
@@ -38,6 +53,18 @@ module MakeTaggable
38
53
  true
39
54
  end
40
55
 
56
+ ##
57
+ # Whether the tags table carries a `type` column, and so is being used for single table
58
+ # inheritance.
59
+ #
60
+ # @return [TrueClass, FalseClass]
61
+ #
62
+ # @api private
63
+ #
64
+ def self.tag_type_column?
65
+ column_names.include?("type")
66
+ end
67
+
41
68
  ### SCOPES:
42
69
  scope :most_used, ->(limit = 20) { order("taggings_count desc").limit(limit) }
43
70
  scope :least_used, ->(limit = 20) { order("taggings_count asc").limit(limit) }
@@ -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("#{MakeTaggable::Tagging.table_name}.tag_id")
111
- tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*").order(options[:order]).limit(options[:limit])
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("#{MakeTaggable::Tagging.table_name}.tag_id, COUNT(#{MakeTaggable::Tagging.table_name}.tag_id) AS tags_count")
151
- tag_scope = MakeTaggable::Tag.select("#{MakeTaggable::Tag.table_name}.*, #{MakeTaggable::Tagging.table_name}.tags_count AS count").order(options[:order]).limit(options[:limit])
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(except(:select).select(table_name_pkey))})")
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: "MakeTaggable::Tag",
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(tags_on(context).map(&:name))
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
- # Returns all tags that are not owned of a given context
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
- scope = base_tags.where(["#{MakeTaggable::Tagging.table_name}.context = ? AND #{MakeTaggable::Tagging.table_name}.tagger_id IS NULL", context.to_s])
476
- # when preserving tag order, return tags in created order
477
- # if we added the order to the association this would always apply
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::Tag.find_or_create_all_with_like_by_name(tag_list)
605
+ MakeTaggable.tag_model.find_or_create_all_with_like_by_name(tag_list)
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 keep a separate vocabulary for one context by returning tags from a
648
- # {MakeTaggable::Tag} subclass.
700
+ # Override it to resolve one context's names through a different class -- one with its own
701
+ # validations or callbacks, say.
702
+ #
703
+ # This routes creation only. Reading gives back whatever {MakeTaggable.tag_class} names, and
704
+ # without a `type` column on the tags table there is nothing to tell a subclass's rows apart, so
705
+ # the vocabularies are not actually separate. See `docs/contexts.md` for the column to add if
706
+ # that is what you are after, and {MakeTaggable.tag_class} for changing the class globally.
649
707
  #
650
- # @example A separate Tag subclass for one context
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: "::MakeTaggable::Tag"
85
+ has_many :base_tags, through: :taggings, source: :tag, class_name: MakeTaggable.tag_class
86
86
 
87
87
  def self.taggable?
88
88
  true
@@ -46,7 +46,7 @@ module MakeTaggable
46
46
  )
47
47
 
48
48
  has_many :owned_tags, -> { distinct },
49
- class_name: "::MakeTaggable::Tag",
49
+ class_name: MakeTaggable.tag_class,
50
50
  source: :tag,
51
51
  through: :owned_taggings
52
52
  end
@@ -30,7 +30,7 @@ module MakeTaggable
30
30
 
31
31
  self.table_name = MakeTaggable.taggings_table
32
32
 
33
- belongs_to :tag, class_name: "::MakeTaggable::Tag", counter_cache: MakeTaggable.tags_counter
33
+ belongs_to :tag, class_name: MakeTaggable.tag_class, counter_cache: MakeTaggable.tags_counter
34
34
  belongs_to :taggable, polymorphic: true
35
35
 
36
36
  belongs_to :tagger, polymorphic: true, optional: true
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.5.0"
9
+ VERSION = "1.7.0"
10
10
  end
data/lib/make_taggable.rb CHANGED
@@ -127,6 +127,17 @@ module MakeTaggable
127
127
  delimiter.end_with?(" ") ? delimiter : "#{delimiter} "
128
128
  end
129
129
 
130
+ ##
131
+ # The class tags are read, written and returned as.
132
+ #
133
+ # Resolved on each call rather than memoised, so a reloaded class in development is picked up.
134
+ #
135
+ # @return [Class]
136
+ #
137
+ def self.tag_model
138
+ tag_class.constantize
139
+ end
140
+
130
141
  ##
131
142
  # The library's settings.
132
143
  #
@@ -168,7 +179,7 @@ module MakeTaggable
168
179
  :remove_unused_tags, :default_parser,
169
180
  :tags_counter, :tags_table,
170
181
  :taggings_table
171
- attr_reader :delimiter, :strict_case_match
182
+ attr_reader :delimiter, :strict_case_match, :tag_class
172
183
 
173
184
  ##
174
185
  # Builds the configuration with the library's defaults.
@@ -186,6 +197,29 @@ module MakeTaggable
186
197
  @force_binary_collation = false
187
198
  @tags_table = :tags
188
199
  @taggings_table = :taggings
200
+ @tag_class = "MakeTaggable::Tag"
201
+ end
202
+
203
+ ##
204
+ # Sets the class tags are read, written and returned as.
205
+ #
206
+ # Must be a String. A model constant cannot be referenced while initializers run -- Zeitwerk
207
+ # has not defined it yet, and eager loading in production would try to resolve it before the
208
+ # class exists.
209
+ #
210
+ # @param class_name [String] the name of a class inheriting from {MakeTaggable::Tag}
211
+ # @return [String]
212
+ # @raise [ArgumentError] when given anything but a String
213
+ #
214
+ def tag_class=(class_name)
215
+ unless class_name.is_a?(String)
216
+ raise ArgumentError,
217
+ "tag_class must be a String, got #{class_name.inspect}. " \
218
+ "Naming the class rather than the constant is what lets it be set in an initializer, " \
219
+ "before Zeitwerk has defined it."
220
+ end
221
+
222
+ @tag_class = class_name
189
223
  end
190
224
 
191
225
  ##
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: make_taggable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
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.