make_taggable 1.3.0 → 1.5.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: 7b11d21dbd46411b5b827d1b70cb44bcb641259804a32fa221597e56e02aa906
4
- data.tar.gz: 3ebf2e595313e19cc88e28ab7541c40bae5fd1c702a09b8f60873fb68605bfcd
3
+ metadata.gz: 1d900a8ac37e0f88ead6c0516fd30ddee5e8cc11c893b7d64e85c090a8b59598
4
+ data.tar.gz: b364eef565e3a171c56558f928360de5230c445b4b210e1aab8f536f9b998c70
5
5
  SHA512:
6
- metadata.gz: 543f460b5117fd847d6f2ed9a7bc694aece6306b3268912710c058cf6bb48275932d1a06ee0adf867492c175ca858cf84794db4238e52b8bad4f2f8ff71fdbc2
7
- data.tar.gz: 9983b5c3084235e612097a52bda93c4bd48799e4c29a8c30b354d2ad8d24bc07db2ec99d4077956e193dfa5c175e21198f3238d912882a7015887d72438ea1a2
6
+ metadata.gz: 611fd4635e580ff5aaaee314600d2d66e7cdb70a2bd22d8988e3eb541c3d632fc25625e485880821beeec8f0bfb71677242f3d0d14ba96e98e0dd4d15038e0ae
7
+ data.tar.gz: b1713ea6a65eb7a8e45dfb0d4b8d41c9d72334cfb8de93ef2c1e60a21a6a4e04758acdcb7761a117b8b275f0aecb3e98d78258a8c7f8389f433836353e676958
@@ -0,0 +1,122 @@
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.
@@ -0,0 +1,74 @@
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.
data/CHANGELOG.md CHANGED
@@ -5,6 +5,100 @@ 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.5.0] - 2026-08-23
9
+
10
+ ### Added
11
+
12
+ - `:on` accepts several contexts as well as one, so a query can search a subset:
13
+ `tagged_with("classic", on: [:genres, :moods], any: true)`. Passing an array previously raised
14
+ `TypeError` from inside Arel.
15
+
16
+ ### Fixed
17
+
18
+ - `force_parameterize` discarded a tag outright when the name had no ASCII in it. `parameterize`
19
+ reduces such a name to an empty string, which was then rejected as blank, so a user tagging in
20
+ Japanese, Greek, Hebrew, Arabic or Cyrillic saved successfully and got back fewer tags than they
21
+ typed. The parameterized form is now kept only where there is one.
22
+
23
+ - `force_binary_collation = true` ran an `ALTER TABLE` every time it was assigned, with no check for
24
+ whether the column already carried that collation. The documented home for it is an initializer,
25
+ which runs once per process, so a deployment issued a schema change against the `tags` table from
26
+ every web worker, background worker, console and rake task -- each one taking a metadata lock that
27
+ other queries queue behind. The current collation is now read first and the statement skipped when
28
+ it matches. The blanket `rescue`/`puts` around it is replaced by an explicit `table_exists?` check.
29
+
30
+ - On PostgreSQL, tag names were matched as `LOWER(name) ILIKE ...`. `ILIKE` already folds case, so
31
+ the `LOWER()` changed nothing while making the expression non-sargable -- no index on `tags.name`
32
+ could be used, including a trigram index built for wild searches. It is skipped on PostgreSQL and
33
+ kept on the adapters that need it.
34
+
35
+ - Taggings are created in tag id order. Each insert bumps the tag's counter cache, so two concurrent
36
+ saves touching the same tags took row locks in whatever order their lists happened to be in, which
37
+ invites deadlocks. Models using `make_ordered_taggable` are unaffected -- there the creation order
38
+ carries the meaning.
39
+
40
+ ## [1.4.0] - 2026-08-23
41
+
42
+ ### Fixed
43
+
44
+ - `find_related_*` raised on `.count`. The relation selects the taggable's columns plus an aliased
45
+ count, and Active Record folded that select list into the `COUNT()` it built. It now counts the
46
+ grouped query as a subquery, so `.count` and `.size` return the number of related records.
47
+
48
+ - `find_related_*` raised on PostgreSQL for any taggable model with a `json` column -- the query
49
+ grouped by every column on the table, and `json` has no equality operator so it cannot appear in
50
+ a `GROUP BY`. It now groups by the primary key on every adapter, which is what the other adapters
51
+ already did. `jsonb` was never affected, so the column type an application picked decided whether
52
+ the feature worked at all.
53
+
54
+ - `TagList` could not be serialised to YAML. Every list carried its parser in an instance variable,
55
+ and that variable holds a Class, which Psych refuses to dump. Anything serialising a model's
56
+ attributes hit it -- change-history gems, Active Job arguments, `serialize` columns. The parser is
57
+ no longer stored; the reader falls back to the configured `MakeTaggable.default_parser`. One
58
+ consequence: a list now follows the configured parser rather than the one in force when it was
59
+ built.
60
+
61
+ - `TagList#remove` ignored a symbol. `remove(:foo)` silently did nothing where `remove("foo")`
62
+ worked.
63
+
64
+ - A tag that failed validation was reported as `Tag can't be blank`, naming the wrong attribute for
65
+ the wrong reason -- reachable with no customisation, since the shipped `Tag` validates name length
66
+ at 255 characters. The error now carries the tag itself, so it says what was actually wrong and
67
+ `error.record` is the tag. `save!` raises; `save` still returns `false` and writes nothing.
68
+
69
+ - Saving a taggable read every tagging on the record, on every save, before establishing whether
70
+ any tag list had been assigned. That cost a query per save and made the gem unusable under
71
+ `strict_loading` -- a record whose tags were never touched still raised
72
+ `StrictLoadingViolationError`. Saves now consider only the contexts held in memory.
73
+
74
+ ### Changed
75
+
76
+ - **Tag lists are no longer declared as Active Model attributes.** The declaration is what gave them
77
+ dirty tracking, and it also made Active Record treat them as columns where they are not. The dirty
78
+ API is unchanged -- `tag_list_changed?`, `_was`, `_change`, `will_save_change_to_*`,
79
+ `saved_change_to_*`, and the tag lists still appear in `changes` -- but it now runs on the gem's
80
+ own bookkeeping.
81
+
82
+ Three visible consequences, each of them the point:
83
+
84
+ - `as_json` no longer includes tag lists, so serialising a collection no longer queries once per
85
+ record. Serialising three records went from 16 queries to 1. Ask for a list explicitly with
86
+ `as_json(methods: :tag_list)`.
87
+ - `attributes` no longer carries a `tag_list` key. It previously held `nil` while `tag_list`
88
+ returned the tags.
89
+ - `upsert_all` still refuses a tag list -- it writes columns, and a tag list means writing
90
+ taggings -- but `as_json` output can now be round-tripped through it, which it could not before.
91
+
92
+ `MakeTaggable::Taggable::TagListType` is removed. It backed the attribute and has no other caller.
93
+
94
+ ### Fixed
95
+
96
+ - `tag_ids = []` silently did nothing once `as_json` had been called on the record.
97
+
98
+ - Mutating a list in place -- `record.tag_list.add("x")` -- did not mark the record as changed, so
99
+ `tag_list_changed?` stayed false and anything conditioned on it never ran. The list still saved;
100
+ what was wrong was everything that asks the record what changed.
101
+
8
102
  ## [1.3.0] - 2026-08-23
9
103
 
10
104
  ### Changed
@@ -40,6 +40,10 @@ Tags are downcased as they are cleaned, so `"Ruby"` is stored as `"ruby"`.
40
40
  Tags are parameterized, so `"Ruby on Rails"` is stored as `"ruby-on-rails"`. Applied after
41
41
  `force_lowercase` if both are on.
42
42
 
43
+ `String#parameterize` strips anything outside a conservative ASCII set, so a name with no ASCII in
44
+ it -- `"日本語"` -- has no slug to reduce to. Such a name is kept as it is rather than parameterized
45
+ into nothing and dropped.
46
+
43
47
  ### `strict_case_match`
44
48
 
45
49
  Off, tag lookups are case insensitive and `"Ruby"` finds `"ruby"`; a list containing both keeps one.
@@ -98,6 +102,12 @@ Note that the shipped migrations already apply `utf8mb4_bin` to the column on My
98
102
  setting adds is `strict_case_match`, which is what actually makes the library's lookups case
99
103
  sensitive — see [database.md](database.md).
100
104
 
105
+ Because the migrations have usually applied the collation already, assigning this in an initializer
106
+ is normally a no-op: the current collation is read first and the `ALTER TABLE` is skipped when it
107
+ already matches. That matters because an initializer runs once per process — every web worker,
108
+ background worker, console and rake task — and each `ALTER TABLE` takes a metadata lock on the tags
109
+ table.
110
+
101
111
  ```ruby
102
112
  MakeTaggable.force_binary_collation = true
103
113
  ```
data/docs/contexts.md CHANGED
@@ -30,16 +30,40 @@ with Active Support's inflector, so `:skills` gives `skill_list` and `skills`.
30
30
  | `find_related_skills_for(klass)` | instance | The same, against another model |
31
31
  | `caching_skill_list?` | class | Whether this context is cached in a column |
32
32
 
33
- `skill_list` takes part in dirty tracking like any other attribute:
33
+ `skill_list` takes part in dirty tracking:
34
34
 
35
35
  ```ruby
36
36
  user.skill_list = "diving"
37
- user.skill_list_changed? # => true
38
- user.skill_list_was # => ["jogging"]
39
- user.skill_list_change # => [["jogging"], ["diving"]]
40
- user.will_save_change_to_skill_list?
37
+ user.skill_list_changed? # => true
38
+ user.skill_list_was # => ["jogging"]
39
+ user.skill_list_change # => [["jogging"], ["diving"]]
40
+ user.will_save_change_to_skill_list? # => true
41
+ user.changes # => {"skill_list" => [["jogging"], ["diving"]]}
42
+ user.save
43
+ user.saved_change_to_skill_list? # => true
44
+ ```
45
+
46
+ Mutating the list in place counts too, not just assigning a new one:
47
+
48
+ ```ruby
49
+ user.skill_list.add("diving")
50
+ user.skill_list_changed? # => true
41
51
  ```
42
52
 
53
+ Order counts only where the model asked for it with `make_ordered_taggable`. Reordering the same
54
+ tags is not a change otherwise.
55
+
56
+ A tag list is **not** an Active Record attribute, though, and the difference shows in three places:
57
+
58
+ ```ruby
59
+ user.attributes["skill_list"] # => nil, and the key is absent -- use user.skill_list
60
+ user.as_json # no "skill_list" key; ask for it with as_json(methods: :skill_list)
61
+ User.upsert_all([{skill_list: "diving"}]) # raises -- upsert_all writes columns, and this is not one
62
+ ```
63
+
64
+ Leaving tag lists out of `as_json` is deliberate: a list is loaded from the taggings table, so
65
+ including it meant serialising a collection queried once per record.
66
+
43
67
  ### Naming a context
44
68
 
45
69
  Because the context becomes part of every name in the table above, it has to be usable as a Ruby
data/docs/database.md CHANGED
@@ -107,8 +107,25 @@ end
107
107
  ## PostgreSQL
108
108
 
109
109
  - `named_like` uses `ILIKE`, so partial matching is case insensitive regardless of collation.
110
- - Tag counts group by every tag column, as PostgreSQL requires.
111
- - Nothing extra is needed for non-ASCII tags.
110
+ - Queries group by the primary key. PostgreSQL wanted every selected non-aggregated column listed
111
+ before 9.1; since then it works the dependency out itself.
112
+
113
+ **Case-insensitive matching of non-ASCII tags depends on the locale the cluster was created with.**
114
+ Exact matching goes through `LOWER(name) = LOWER(?)`, and `LOWER()` follows `lc_ctype`. On a UTF-8
115
+ locale it folds Cyrillic, Greek and the rest as you would expect. On a cluster initialised with
116
+ `lc_ctype = C` it folds ASCII only, and `"ПРИВЕТ"` and `"привет"` become two separate tags — the same
117
+ limitation described under SQLite below, on a database that otherwise has none.
118
+
119
+ Check with:
120
+
121
+ ```sql
122
+ SHOW lc_ctype; -- C means ASCII-only folding
123
+ SELECT LOWER('Ü'); -- returns 'Ü' unchanged on such a cluster
124
+ ```
125
+
126
+ `ILIKE` is unaffected, so partial matching keeps working either way. If you need exact matching to
127
+ fold non-ASCII, create the database with a UTF-8 locale, or set
128
+ `MakeTaggable.strict_case_match = true` so the behaviour is at least consistent.
112
129
 
113
130
  ## MySQL
114
131
 
@@ -156,3 +173,20 @@ or set `MakeTaggable.strict_case_match = true` so the behaviour is at least cons
156
173
 
157
174
  `name` is validated at 255 characters, and the column is a `string`. Tags longer than that fail
158
175
  validation rather than being truncated.
176
+
177
+ A tag that cannot be saved fails the save of the record being tagged. `save!` raises
178
+ `ActiveRecord::RecordInvalid` carrying the **tag**, so the error says what was wrong with it and
179
+ `error.record` is the tag itself; `save` returns `false` and writes nothing.
180
+
181
+ The same applies to a `Tag` subclass with validations of its own:
182
+
183
+ ```ruby
184
+ class PickyTag < MakeTaggable::Tag
185
+ validate { errors.add(:name, "must not be rude") if name.to_s.include?("rude") }
186
+ end
187
+
188
+ article.save!
189
+ # => ActiveRecord::RecordInvalid: Validation failed: Name must not be rude
190
+ ```
191
+
192
+ See [contexts.md](contexts.md) for wiring a `Tag` subclass to a context.
data/docs/querying.md CHANGED
@@ -28,12 +28,18 @@ empty relation rather than every record — worth knowing when the tags come fro
28
28
  | `:exclude` | Match records carrying none of the tags |
29
29
  | `:match_all` | Match records carrying only these tags and no others |
30
30
  | `:wild` | Match tags *containing* the given text, i.e. `%sci%` |
31
- | `:on` | Restrict to one context. Honoured by every option, `:exclude` included |
31
+ | `:on` | Restrict to one context, or an array of them. Honoured by every option, `:exclude` included |
32
32
  | `:owned_by` | Restrict to tags applied by one tagger |
33
33
  | `:order_by_matching_tag_count` | Order by how many matching taggings a record has, most first. No effect with `:match_all` |
34
34
  | `:start_at` | Only tags applied after this time. Honoured by every option, `:exclude` included |
35
35
  | `:end_at` | Only tags applied before this time. Honoured by every option, `:exclude` included |
36
36
 
37
+ `:on` takes several contexts as well as one, for searching a subset:
38
+
39
+ ```ruby
40
+ Book.tagged_with("classic", on: [:genres, :moods], any: true)
41
+ ```
42
+
37
43
  An empty tag list means "nothing matches" for the matching options and "nothing is ruled out" for
38
44
  `:exclude`, so the two always partition the scope between them:
39
45
 
@@ -181,6 +187,16 @@ its own — add `.distinct` if you want records back rather than matches.
181
187
  is what stops a record being returned once per matching tagging. Matching a dozen tags produces a
182
188
  dozen correlated subqueries rather than a dozen joins, which most planners handle better, but it
183
189
  is still worth reaching for `any: true` where the semantics allow it.
190
+ - Saving a taggable reads nothing from the taggings table unless a tag list was actually assigned,
191
+ so a save that touches no tags costs no extra query and works under `strict_loading`.
192
+ - On PostgreSQL, tag names are matched with `ILIKE` against the column itself rather than through
193
+ `LOWER()`, so an index on `tags.name` can be used. A wild search is the case that most wants one:
194
+
195
+ ```sql
196
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
197
+ CREATE INDEX index_tags_on_name_trgm ON tags USING gin (name gin_trgm_ops);
198
+ ```
199
+
184
200
  - `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
185
201
  page of results and expensive across a whole table.
186
202
  - `all_tag_counts` joins the taggables to count them. Reach for `all_tags` when the counts are not
@@ -24,7 +24,7 @@ module MakeTaggable
24
24
  #
25
25
  class TagList < Array
26
26
  attr_accessor :owner
27
- attr_accessor :parser
27
+ attr_writer :parser
28
28
 
29
29
  ##
30
30
  # Builds a tag list from the given names.
@@ -34,10 +34,26 @@ module MakeTaggable
34
34
  # @return [MakeTaggable::TagList]
35
35
  #
36
36
  def initialize(*args)
37
- @parser = MakeTaggable.default_parser
38
37
  add(*args)
39
38
  end
40
39
 
40
+ ##
41
+ # The parser this list uses when asked to parse.
42
+ #
43
+ # Falls back to the configured {MakeTaggable.default_parser}, and deliberately does not store
44
+ # it. A stored parser is a Class held in an instance variable, and Psych validates instance
45
+ # variables when dumping, so carrying one made every tag list unserialisable -- `audited`,
46
+ # Active Job arguments and `serialize` columns all refuse it.
47
+ #
48
+ # A parser assigned explicitly is still stored, and a list carrying one is subject to the same
49
+ # limitation.
50
+ #
51
+ # @return [Class]
52
+ #
53
+ def parser
54
+ @parser || MakeTaggable.default_parser
55
+ end
56
+
41
57
  ##
42
58
  # Adds tags to the list, ignoring duplicates and blanks.
43
59
  #
@@ -101,6 +117,13 @@ module MakeTaggable
101
117
  #
102
118
  def remove(*names)
103
119
  extract_and_apply_options!(names)
120
+
121
+ # The list holds strings, so compare strings. Everything else that takes
122
+ # tag names normalises them -- add runs them through clean!, tagged_with
123
+ # parses them -- and a symbol silently matching nothing here was the odd
124
+ # one out.
125
+ names = names.map(&:to_s)
126
+
104
127
  delete_if { |name| names.include?(name) }
105
128
  self
106
129
  end
@@ -135,7 +158,11 @@ module MakeTaggable
135
158
  map!(&:to_s)
136
159
  map!(&:strip)
137
160
  map!(&:downcase) if MakeTaggable.force_lowercase
138
- map!(&:parameterize) if MakeTaggable.force_parameterize
161
+ # A name with no ASCII in it parameterizes to "", and reject! below would
162
+ # then drop it -- so the tag vanished rather than being slugged. Keep the
163
+ # original where there is no slug to be had; a caller who wanted strict
164
+ # slugs still gets one wherever one exists.
165
+ map! { |tag| tag.parameterize.presence || tag } if MakeTaggable.force_parameterize
139
166
 
140
167
  MakeTaggable.strict_case_match ? uniq! : uniq! { |tag| tag.downcase }
141
168
  self
@@ -145,9 +172,9 @@ module MakeTaggable
145
172
  options = args.last.is_a?(Hash) ? args.pop : {}
146
173
  options.assert_valid_keys :parse, :parser
147
174
 
148
- parser = options[:parser] || @parser
175
+ chosen_parser = options[:parser] || parser
149
176
 
150
- args.map! { |a| parser.new(a).parse } if options[:parse] || options[:parser]
177
+ args.map! { |a| chosen_parser.new(a).parse } if options[:parse] || options[:parser]
151
178
 
152
179
  args.flatten!
153
180
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tagged_with_query"
4
- require_relative "tag_list_type"
5
4
 
6
5
  module MakeTaggable::Taggable
7
6
  ##
@@ -58,8 +57,6 @@ module MakeTaggable::Taggable
58
57
  class_name: "MakeTaggable::Tag",
59
58
  through: context_taggings,
60
59
  source: :tag
61
-
62
- attribute :"#{tags_type.singularize}_list", MakeTaggable::Taggable::TagListType.new
63
60
  end
64
61
 
65
62
  taggable_mixin.class_eval <<-RUBY, __FILE__, __LINE__ + 1
@@ -68,15 +65,6 @@ module MakeTaggable::Taggable
68
65
  end
69
66
 
70
67
  def #{tag_type}_list=(new_tags)
71
- parsed_new_list = MakeTaggable.default_parser.new(new_tags).parse
72
-
73
- if self.class.preserve_tag_order? || (parsed_new_list.sort != #{tag_type}_list.sort)
74
- unless #{tag_type}_list_changed?
75
- @attributes["#{tag_type}_list"] = ActiveModel::Attribute.from_user("#{tag_type}_list", #{tag_type}_list, MakeTaggable::Taggable::TagListType.new)
76
- end
77
- write_attribute("#{tag_type}_list", parsed_new_list)
78
- end
79
-
80
68
  set_tag_list_on('#{tags_type}', new_tags)
81
69
  end
82
70
 
@@ -84,9 +72,33 @@ module MakeTaggable::Taggable
84
72
  all_tags_list_on('#{tags_type}')
85
73
  end
86
74
 
75
+ def #{tag_type}_list_changed?
76
+ tag_list_changed_on?('#{tags_type}')
77
+ end
78
+
79
+ def #{tag_type}_list_was
80
+ tag_list_was_on('#{tags_type}')
81
+ end
82
+
83
+ def #{tag_type}_list_change
84
+ tag_list_change_on('#{tags_type}')
85
+ end
86
+
87
+ def will_save_change_to_#{tag_type}_list?
88
+ tag_list_changed_on?('#{tags_type}')
89
+ end
90
+
91
+ def saved_change_to_#{tag_type}_list
92
+ saved_tag_list_changes['#{tag_type}_list']
93
+ end
94
+
95
+ def saved_change_to_#{tag_type}_list?
96
+ saved_tag_list_changes.key?('#{tag_type}_list')
97
+ end
98
+
87
99
  private
88
100
  def dirtify_tag_list(tagging)
89
- attribute_will_change! tagging.context.singularize+"_list"
101
+ tag_list_changed_by_association(tagging.context)
90
102
  end
91
103
  RUBY
92
104
  end
@@ -106,7 +118,17 @@ module MakeTaggable::Taggable
106
118
  initialize_make_taggable_core
107
119
  end
108
120
 
109
- # all column names are necessary for PostgreSQL group clause
121
+ ##
122
+ # Every column on a table, qualified, joined for a `GROUP BY` clause.
123
+ #
124
+ # Nothing in the library needs this any more. PostgreSQL wanted every selected
125
+ # non-aggregated column in the `GROUP BY` before 9.1; since then the primary key is enough,
126
+ # and that is what the library groups by. Kept because it is public and useful for building
127
+ # such a clause by hand.
128
+ #
129
+ # @param object [Class] the model whose columns to list
130
+ # @return [String]
131
+ #
110
132
  def grouped_column_names_for(object)
111
133
  object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
112
134
  end
@@ -167,7 +189,12 @@ module MakeTaggable::Taggable
167
189
  end
168
190
  end
169
191
 
170
- # all column names are necessary for PostgreSQL group clause
192
+ ##
193
+ # @see ClassMethods#grouped_column_names_for
194
+ #
195
+ # @param object [Class] the model whose columns to list
196
+ # @return [String]
197
+ #
171
198
  def grouped_column_names_for(object)
172
199
  self.class.grouped_column_names_for(object)
173
200
  end
@@ -192,7 +219,13 @@ module MakeTaggable::Taggable
192
219
  # @return [Array<String>, NilClass]
193
220
  #
194
221
  def add_custom_context(value)
195
- custom_contexts << value.to_s unless custom_contexts.include?(value.to_s) || self.class.tag_types.map(&:to_s).include?(value.to_s)
222
+ # The declared contexts are checked first deliberately. Ruby evaluates the
223
+ # left operand of `||` first, so testing custom_contexts up front loaded
224
+ # every tagging on the record just to assign a list on an ordinary
225
+ # declared context.
226
+ return if self.class.tag_types.map(&:to_s).include?(value.to_s)
227
+
228
+ custom_contexts << value.to_s unless custom_contexts.include?(value.to_s)
196
229
  end
197
230
 
198
231
  ##
@@ -216,6 +249,151 @@ module MakeTaggable::Taggable
216
249
  instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
217
250
  end
218
251
 
252
+ ##
253
+ # The tag lists as they stood when the record was loaded or last saved, keyed by context.
254
+ #
255
+ # @return [Hash{String => Array<String>}]
256
+ #
257
+ # @api private
258
+ #
259
+ def original_tag_lists
260
+ @original_tag_lists ||= {}
261
+ end
262
+
263
+ ##
264
+ # Records what a context's list looked like before anything touched it, if that has not been
265
+ # noted already. Called before every change, so the first note wins and later ones are ignored.
266
+ #
267
+ # @param context [Symbol, String] the tagging context
268
+ # @return [void]
269
+ #
270
+ # @api private
271
+ #
272
+ def note_tag_list_original(context)
273
+ key = context.to_s
274
+ return if original_tag_lists.key?(key)
275
+
276
+ original_tag_lists[key] = tag_list_cache_on(key).to_a.dup
277
+ end
278
+
279
+ ##
280
+ # Notes the original list and drops the cached one, after a tagging was added or removed
281
+ # through the association rather than through a list.
282
+ #
283
+ # The cached list is what `tag_list_on` answers from, and pushing onto `record.tags` does not
284
+ # go near it -- so without dropping it the list before and after compare equal and nothing
285
+ # looks changed.
286
+ #
287
+ # @param context [Symbol, String] the tagging context
288
+ # @return [void]
289
+ #
290
+ # @api private
291
+ #
292
+ def tag_list_changed_by_association(context)
293
+ note_tag_list_original(context)
294
+
295
+ singular = context.to_s.singularize
296
+ instance_variable_set("@#{singular}_list", nil)
297
+ instance_variable_set("@all_#{singular}_list", nil)
298
+ end
299
+
300
+ ##
301
+ # Whether a context's list differs from the one loaded or last saved.
302
+ #
303
+ # Order counts only where the model asked for it with `make_ordered_taggable`.
304
+ #
305
+ # @param context [Symbol, String] the tagging context
306
+ # @return [TrueClass, FalseClass]
307
+ #
308
+ def tag_list_changed_on?(context)
309
+ key = context.to_s
310
+ return false unless original_tag_lists.key?(key)
311
+
312
+ comparable_tag_list(original_tag_lists[key]) != comparable_tag_list(tag_list_on(key))
313
+ end
314
+
315
+ ##
316
+ # A context's list as it stood when the record was loaded or last saved.
317
+ #
318
+ # @param context [Symbol, String] the tagging context
319
+ # @return [Array<String>]
320
+ #
321
+ def tag_list_was_on(context)
322
+ key = context.to_s
323
+
324
+ original_tag_lists.key?(key) ? original_tag_lists[key] : tag_list_on(key).to_a
325
+ end
326
+
327
+ ##
328
+ # A context's list before and after, or `nil` when it has not changed.
329
+ #
330
+ # @param context [Symbol, String] the tagging context
331
+ # @return [Array<Array<String>>, NilClass]
332
+ #
333
+ def tag_list_change_on(context)
334
+ return unless tag_list_changed_on?(context)
335
+
336
+ [tag_list_was_on(context), tag_list_on(context).to_a]
337
+ end
338
+
339
+ ##
340
+ # The tag list changes this record is carrying, keyed the way Active Model keys `changes`.
341
+ #
342
+ # @return [Hash{String => Array<Array<String>>}]
343
+ #
344
+ # @api private
345
+ #
346
+ def tag_list_changes
347
+ # assigned_tagging_contexts, not tagging_contexts: the latter reads the
348
+ # taggings table to find contexts used previously, and a record only has
349
+ # pending changes in contexts it holds a list for.
350
+ assigned_tagging_contexts.each_with_object({}) do |context, changes|
351
+ change = tag_list_change_on(context)
352
+ changes["#{context.to_s.singularize}_list"] = change if change
353
+ end
354
+ end
355
+
356
+ ##
357
+ # The tag list changes written by the most recent save.
358
+ #
359
+ # @return [Hash{String => Array<Array<String>>}]
360
+ #
361
+ # @api private
362
+ #
363
+ def saved_tag_list_changes
364
+ @saved_tag_list_changes ||= {}
365
+ end
366
+
367
+ ##
368
+ # Active Model's changes, plus the tag lists.
369
+ #
370
+ # @return [ActiveSupport::HashWithIndifferentAccess]
371
+ #
372
+ def changes
373
+ super.merge(tag_list_changes)
374
+ end
375
+
376
+ ##
377
+ # @return [Hash] the previous values of everything changed, tag lists included
378
+ #
379
+ def changed_attributes
380
+ super.merge(tag_list_changes.transform_values(&:first))
381
+ end
382
+
383
+ ##
384
+ # @return [TrueClass, FalseClass] whether anything changed, tag lists included
385
+ #
386
+ def changed?
387
+ super || tag_list_changes.any?
388
+ end
389
+
390
+ ##
391
+ # @return [Hash] the changes the last save wrote, tag lists included
392
+ #
393
+ def saved_changes
394
+ super.merge(saved_tag_list_changes)
395
+ end
396
+
219
397
  ##
220
398
  # A context's tag list, loading it from the caching column or the database as needed.
221
399
  #
@@ -224,13 +402,22 @@ module MakeTaggable::Taggable
224
402
  #
225
403
  def tag_list_cache_on(context)
226
404
  variable_name = "@#{context.to_s.singularize}_list"
227
- if instance_variable_get(variable_name)
228
- instance_variable_get(variable_name)
229
- elsif cached_tag_list_on(context) && ensure_included_cache_methods! && self.class.caching_tag_list_on?(context)
230
- instance_variable_set(variable_name, MakeTaggable.default_parser.new(cached_tag_list_on(context)).parse)
231
- else
232
- instance_variable_set(variable_name, MakeTaggable::TagList.new(tags_on(context).map(&:name)))
233
- end
405
+ return instance_variable_get(variable_name) if instance_variable_get(variable_name)
406
+
407
+ list =
408
+ if cached_tag_list_on(context) && ensure_included_cache_methods! && self.class.caching_tag_list_on?(context)
409
+ MakeTaggable.default_parser.new(cached_tag_list_on(context)).parse
410
+ else
411
+ MakeTaggable::TagList.new(tags_on(context).map(&:name))
412
+ end
413
+
414
+ # Note what was there the first time the list is built, before anything
415
+ # can have touched it. A caller holding this list can mutate it in place
416
+ # -- tag_list.add("x") -- which never goes through the writer, so this is
417
+ # the only chance to see the original.
418
+ original_tag_lists[context.to_s] ||= list.to_a.dup
419
+
420
+ instance_variable_set(variable_name, list)
234
421
  end
235
422
 
236
423
  ##
@@ -270,11 +457,15 @@ module MakeTaggable::Taggable
270
457
  opts = ["#{tagging_table_name}.context = ?", context.to_s]
271
458
  scope = base_tags.where(opts)
272
459
 
460
+ group_columns = "#{MakeTaggable::Tag.table_name}.#{MakeTaggable::Tag.primary_key}"
461
+
273
462
  if MakeTaggable::Utils.using_postgresql?
274
- group_columns = grouped_column_names_for(MakeTaggable::Tag)
463
+ # Ordering by an aggregate is why this groups at all. Grouping by the
464
+ # primary key alone is enough on every supported PostgreSQL -- it works
465
+ # the functional dependency out itself, and has since 9.1.
275
466
  scope.order(Arel.sql("max(#{tagging_table_name}.created_at)")).group(group_columns)
276
467
  else
277
- scope.group("#{MakeTaggable::Tag.table_name}.#{MakeTaggable::Tag.primary_key}")
468
+ scope.group(group_columns)
278
469
  end.to_a
279
470
  end
280
471
 
@@ -303,6 +494,10 @@ module MakeTaggable::Taggable
303
494
  def set_tag_list_on(context, new_list)
304
495
  add_custom_context(context)
305
496
 
497
+ # Before the list is replaced, so the note captures what was there rather
498
+ # than what is being put there.
499
+ note_tag_list_original(context)
500
+
306
501
  variable_name = "@#{context.to_s.singularize}_list"
307
502
 
308
503
  parsed_new_list = MakeTaggable.default_parser.new(new_list).parse
@@ -319,6 +514,23 @@ module MakeTaggable::Taggable
319
514
  self.class.tag_types.map(&:to_s) + custom_contexts
320
515
  end
321
516
 
517
+ ##
518
+ # The contexts a save has to consider: the declared ones, plus any context this record has
519
+ # been handed a list for in memory.
520
+ #
521
+ # Deliberately not {#tagging_contexts}, which reads the taggings table to find contexts used
522
+ # previously. A save only writes lists held in memory, so the ones already loaded are the only
523
+ # ones that can have anything to write -- and reading the table on every save cost a query
524
+ # whether or not any tag changed, and broke `strict_loading` outright.
525
+ #
526
+ # @return [Array<String>]
527
+ #
528
+ # @api private
529
+ #
530
+ def assigned_tagging_contexts
531
+ self.class.tag_types.map(&:to_s) + (@custom_contexts || [])
532
+ end
533
+
322
534
  ##
323
535
  # Reloads the record, discarding the tag lists held in memory.
324
536
  #
@@ -346,7 +558,7 @@ module MakeTaggable::Taggable
346
558
  # @return [TrueClass]
347
559
  #
348
560
  def save_tags
349
- tagging_contexts.each do |context|
561
+ assigned_tagging_contexts.each do |context|
350
562
  next unless tag_list_cache_set_on(context)
351
563
 
352
564
  # List of currently assigned tag names
@@ -355,6 +567,13 @@ module MakeTaggable::Taggable
355
567
  # Find existing tags or create non-existing tags:
356
568
  tags = find_or_create_tags_from_list_with_context(tag_list, context)
357
569
 
570
+ # A tag that failed its own validation comes back unsaved, with a nil
571
+ # id. Left alone it reaches taggings.create! as `tag_id: nil`, and the
572
+ # caller is told "Tag can't be blank" -- the wrong attribute, and no
573
+ # sign of what was actually wrong. Report the tag itself instead.
574
+ unsaved = tags.reject(&:persisted?)
575
+ raise ActiveRecord::RecordInvalid.new(unsaved.first) if unsaved.any?
576
+
358
577
  # Tag objects for currently assigned tags
359
578
  current_tags = tags_on(context)
360
579
 
@@ -387,31 +606,39 @@ module MakeTaggable::Taggable
387
606
  taggings.not_owned.by_context(context).where(tag_id: old_tags).destroy_all
388
607
  end
389
608
 
390
- # Create new taggings:
609
+ # Create new taggings, in a consistent order. Each insert bumps the tag's
610
+ # counter cache, so two concurrent saves touching the same tags would
611
+ # otherwise take row locks in whatever order their lists happened to be
612
+ # in, and deadlock. Ordering is skipped where the model asked for tag
613
+ # order to be preserved, since there the creation order is the point.
614
+ new_tags = new_tags.sort_by(&:id) unless self.class.preserve_tag_order?
615
+
391
616
  new_tags.each do |tag|
392
617
  taggings.create!(tag_id: tag.id, context: context.to_s, taggable: self)
393
618
  end
394
619
  end
395
620
 
621
+ settle_tag_list_changes
622
+
396
623
  true
397
624
  end
398
625
 
399
- private
400
-
401
- def ensure_included_cache_methods!
402
- self.class.columns
626
+ # Moves the pending tag list changes into the saved ones, so that after a
627
+ # save the record reports what the save wrote rather than what it was about
628
+ # to write. Mirrors what Active Model does for real attributes.
629
+ def settle_tag_list_changes
630
+ @saved_tag_list_changes = tag_list_changes
631
+ original_tag_lists.clear
403
632
  end
404
633
 
405
- # Filters the tag lists from the attribute names.
406
- def attributes_for_update(attribute_names)
407
- tag_lists = tag_types.map { |tags_type| "#{tags_type.to_s.singularize}_list" }
408
- super.delete_if { |attr| tag_lists.include? attr }
634
+ private
635
+
636
+ def comparable_tag_list(list)
637
+ self.class.preserve_tag_order? ? list.to_a : list.to_a.sort
409
638
  end
410
639
 
411
- # Filters the tag lists from the attribute names.
412
- def attributes_for_create(attribute_names)
413
- tag_lists = tag_types.map { |tags_type| "#{tags_type.to_s.singularize}_list" }
414
- super.delete_if { |attr| tag_lists.include? attr }
640
+ def ensure_included_cache_methods!
641
+ self.class.columns
415
642
  end
416
643
 
417
644
  ##
@@ -164,7 +164,7 @@ module MakeTaggable::Taggable
164
164
  # @return [TrueClass]
165
165
  #
166
166
  def save_owned_tags
167
- tagging_contexts.each do |context|
167
+ assigned_tagging_contexts.each do |context|
168
168
  cached_owned_tag_list_on(context).each do |owner, tag_list|
169
169
  # Find existing tags or create non-existing tags:
170
170
  tags = find_or_create_tags_from_list_with_context(tag_list.uniq, context)
@@ -202,7 +202,10 @@ module MakeTaggable::Taggable
202
202
  tag_id: old_tags, context: context).destroy_all
203
203
  end
204
204
 
205
- # Create new taggings:
205
+ # Create new taggings, in a consistent order -- see the note in
206
+ # Core#save_tags on why the order matters.
207
+ new_tags = new_tags.sort_by(&:id) unless self.class.preserve_tag_order?
208
+
206
209
  new_tags.each do |tag|
207
210
  taggings.create!(tag_id: tag.id, context: context.to_s, tagger: owner, taggable: self)
208
211
  end
@@ -115,12 +115,15 @@ module MakeTaggable::Taggable
115
115
  "#{klass.arel_table[klass.primary_key].not_eq(id).to_sql} AND" if [self.class.base_class, self.class].include? klass
116
116
  end
117
117
 
118
+ # Grouping by the primary key alone is enough on every adapter we support.
119
+ #
120
+ # PostgreSQL used to need every selected non-aggregated column listed here,
121
+ # which is why this branched. Since 9.1 it works the functional dependency
122
+ # out from the primary key on its own, and listing every column actively
123
+ # breaks a model with a `json` column -- json has no equality operator, so
124
+ # it cannot appear in a GROUP BY at all.
118
125
  def group_columns(klass)
119
- if MakeTaggable::Utils.using_postgresql?
120
- grouped_column_names_for(klass)
121
- else
122
- "#{klass.table_name}.#{klass.primary_key}"
123
- end
126
+ "#{klass.table_name}.#{klass.primary_key}"
124
127
  end
125
128
 
126
129
  def related_where(klass, conditions)
@@ -129,6 +132,42 @@ module MakeTaggable::Taggable
129
132
  .group(group_columns(klass))
130
133
  .order("count DESC")
131
134
  .where(conditions)
135
+ .extending(CalculationMethods)
136
+ end
137
+
138
+ # These relations select the taggable's columns plus an aliased count, and
139
+ # order by that alias. Active Record folds the select list into the COUNT()
140
+ # it builds, which produced SQL no adapter accepts:
141
+ #
142
+ # SELECT COUNT(taggable_models.*, COUNT(tags.id) AS count) ...
143
+ #
144
+ # Counting rows instead is not enough on its own either: the relation groups
145
+ # by primary key over a cross join, so dropping the grouping counts taggings
146
+ # rather than records, and the ORDER BY still names an alias that a bare
147
+ # COUNT(*) no longer selects.
148
+ #
149
+ # So count the grouped query as a subquery. `klass.unscoped` is the shell
150
+ # for it because it carries no extension of its own -- counting a relation
151
+ # derived from this one would re-enter this method.
152
+ module CalculationMethods
153
+ ##
154
+ # The number of related records.
155
+ #
156
+ # @param column_name [Symbol, String] accepted for signature compatibility; ignored
157
+ # @return [Integer]
158
+ #
159
+ def count(column_name = :all)
160
+ ids = except(:select, :order).select(Arel.sql("#{klass.table_name}.#{klass.primary_key}"))
161
+
162
+ klass.unscoped.from(Arel.sql("(#{ids.to_sql}) AS #{klass.table_name}_related")).count(:all)
163
+ end
164
+
165
+ ##
166
+ # @return [Integer] the number of related records
167
+ #
168
+ def size
169
+ loaded? ? to_a.size : count
170
+ end
132
171
  end
133
172
  end
134
173
  end
@@ -56,7 +56,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
56
56
  end
57
57
 
58
58
  if options[:on].present?
59
- condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
59
+ condition = condition.and(context_predicate)
60
60
  end
61
61
 
62
62
  if (owner = options[:owned_by]).present?
@@ -92,7 +92,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
92
92
  end
93
93
 
94
94
  if options[:on].present?
95
- on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
95
+ on_condition = on_condition.and(context_predicate)
96
96
  end
97
97
 
98
98
  on_condition
@@ -28,7 +28,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
28
28
  # Left off, the subquery gathers taggings from other contexts and other
29
29
  # times, and excludes records on the strength of them.
30
30
  if options[:on].present?
31
- on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
31
+ on_condition = on_condition.and(context_predicate)
32
32
  end
33
33
 
34
34
  if options[:start_at].present?
@@ -85,7 +85,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
85
85
  end
86
86
 
87
87
  if options[:on].present?
88
- on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
88
+ on_condition = on_condition.and(context_predicate)
89
89
  end
90
90
 
91
91
  on_condition
@@ -40,8 +40,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
40
40
  end
41
41
 
42
42
  def tag_match_type(tag)
43
- matches_attribute = tag_arel_table[:name]
44
- matches_attribute = matches_attribute.lower unless MakeTaggable.strict_case_match
43
+ matches_attribute = folded_name_attribute
45
44
 
46
45
  if options[:wild].present?
47
46
  matches_attribute.matches("%#{escaped_tag(tag)}%", "!", MakeTaggable.strict_case_match)
@@ -51,8 +50,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
51
50
  end
52
51
 
53
52
  def tags_match_type
54
- matches_attribute = tag_arel_table[:name]
55
- matches_attribute = matches_attribute.lower unless MakeTaggable.strict_case_match
53
+ matches_attribute = folded_name_attribute
56
54
 
57
55
  if options[:wild].present?
58
56
  matches_attribute.matches_any(tag_list.map { |tag| "%#{escaped_tag(tag)}%" }, "!", MakeTaggable.strict_case_match)
@@ -85,7 +83,7 @@ module MakeTaggable::Taggable::TaggedWithQuery
85
83
  end
86
84
 
87
85
  if options[:on].present?
88
- condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
86
+ condition = condition.and(context_predicate)
89
87
  end
90
88
 
91
89
  if (owner = options[:owned_by]).present?
@@ -101,6 +99,32 @@ module MakeTaggable::Taggable::TaggedWithQuery
101
99
  "(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{matching_taggings.to_sql}) desc"
102
100
  end
103
101
 
102
+ # The predicate restricting a query to the context or contexts asked for.
103
+ #
104
+ # `:on` takes one context or several, so a caller can search a subset without either naming a
105
+ # single one or falling back to every context there is.
106
+ def context_predicate(tagging_table = tagging_arel_table)
107
+ contexts = Array(options[:on]).map(&:to_s)
108
+
109
+ contexts.one? ? tagging_table[:context].eq(contexts.first) : tagging_table[:context].in(contexts)
110
+ end
111
+
112
+ # The tag name attribute to match against, folded where the comparison will not fold it itself.
113
+ #
114
+ # PostgreSQL matches with ILIKE, which is already case-insensitive, so wrapping the column in
115
+ # LOWER() there changes nothing and costs everything -- the expression stops being sargable, so
116
+ # no index on tags.name can be used, including a trigram index built for wild searches.
117
+ #
118
+ # The other adapters do need it. The MySQL migration collates tags.name as utf8mb4_bin, which
119
+ # makes LIKE case-sensitive, and folding keeps SQLite consistent with the rest.
120
+ def folded_name_attribute
121
+ attribute = tag_arel_table[:name]
122
+
123
+ return attribute if MakeTaggable.strict_case_match || MakeTaggable::Utils.using_postgresql?
124
+
125
+ attribute.lower
126
+ end
127
+
104
128
  def escaped_tag(tag)
105
129
  tag = tag.downcase unless MakeTaggable.strict_case_match
106
130
  MakeTaggable::Utils.escape_like(tag)
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.3.0"
9
+ VERSION = "1.5.0"
10
10
  end
data/lib/make_taggable.rb CHANGED
@@ -47,7 +47,6 @@ module MakeTaggable
47
47
  autoload :Core
48
48
  autoload :Ownership
49
49
  autoload :Related
50
- autoload :TagListType
51
50
  end
52
51
 
53
52
  autoload :Utils
@@ -255,16 +254,40 @@ module MakeTaggable
255
254
  # `utf8mb4_general_ci`
256
255
  # @return [NilClass]
257
256
  #
257
+ ##
258
+ # Applies the collation `tags.name` should carry on MySQL, if it does not carry it already.
259
+ #
260
+ # This is a schema change, and the documented way to reach it is an initializer -- which runs
261
+ # once per process, so once per web worker, background worker, console and rake task. Issuing
262
+ # `ALTER TABLE` from each of those takes a metadata lock on the tags table every time. So the
263
+ # current collation is read first and the statement skipped when it already matches, which
264
+ # turns the common case into one cheap catalogue read.
265
+ #
266
+ # @param bincoll [TrueClass, FalseClass] whether to apply the binary collation
267
+ # @return [void]
268
+ #
258
269
  def self.apply_binary_collation(bincoll)
259
- if Utils.using_mysql?
260
- coll = "utf8mb4_general_ci"
261
- coll = "utf8mb4_bin" if bincoll
262
- begin
263
- ActiveRecord::Migration.execute("ALTER TABLE #{Tag.table_name} MODIFY name varchar(255) CHARACTER SET utf8mb4 COLLATE #{coll};")
264
- rescue => e
265
- puts "Trapping #{e.class}: collation parameter ignored while migrating for the first time."
266
- end
267
- end
270
+ return unless Utils.using_mysql?
271
+
272
+ collation = bincoll ? "utf8mb4_bin" : "utf8mb4_general_ci"
273
+
274
+ # Nothing to apply to yet -- this runs during the first migration, before
275
+ # the table exists.
276
+ return unless Utils.connection.table_exists?(Tag.table_name)
277
+ return if current_tag_name_collation == collation
278
+
279
+ ActiveRecord::Migration.execute(
280
+ "ALTER TABLE #{Tag.table_name} MODIFY name varchar(255) CHARACTER SET utf8mb4 COLLATE #{collation};"
281
+ )
282
+ end
283
+
284
+ ##
285
+ # The collation `tags.name` currently carries, or `nil` where it cannot be read.
286
+ #
287
+ # @return [String, NilClass]
288
+ #
289
+ def self.current_tag_name_collation
290
+ Utils.connection.columns(Tag.table_name).find { |column| column.name == "name" }&.collation
268
291
  end
269
292
  end
270
293
  setup
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.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy
@@ -33,6 +33,8 @@ executables: []
33
33
  extensions: []
34
34
  extra_rdoc_files: []
35
35
  files:
36
+ - AATO_ISSUE_TRIAGE.md
37
+ - AATO_UPSTREAM_COMMIT_REVIEW.md
36
38
  - CHANGELOG.md
37
39
  - CODE_OF_CONDUCT.md
38
40
  - CONTRIBUTING.md
@@ -70,7 +72,6 @@ files:
70
72
  - lib/make_taggable/taggable/core.rb
71
73
  - lib/make_taggable/taggable/ownership.rb
72
74
  - lib/make_taggable/taggable/related.rb
73
- - lib/make_taggable/taggable/tag_list_type.rb
74
75
  - lib/make_taggable/taggable/tagged_with_query.rb
75
76
  - lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb
76
77
  - lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module MakeTaggable::Taggable
4
- ##
5
- # The Active Model type backing each generated `*_list` attribute, so tag lists take part in
6
- # dirty tracking alongside ordinary columns.
7
- #
8
- # @api private
9
- #
10
- class TagListType < ActiveModel::Type::Value
11
- end
12
- end