make_taggable 1.2.1 → 1.4.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: 99cb64f83f0cef74cc21f3fc09a341e62db5a2248b8c88ef5d720de4b9446abb
4
- data.tar.gz: c3c08be5a20df6100567523555be2394dc32665770f6c65226e0fe91b3be5652
3
+ metadata.gz: 1b40ab74ad5312aa300ba26015e4fc7d5da67e5e37c5f4c7efb235aa494e28d0
4
+ data.tar.gz: 84db5687e59dfb33259222e47bc58fdb7d94dd35882faafc2f1ceede7effc841
5
5
  SHA512:
6
- metadata.gz: ad8701479ff9565786acebff64af05fe5aaa88eddbcfc660f88778e4ca885a0d26438677754cfa3072dd54e3eef6374ebed4f1e4de6a70fe911824050cb0b19e
7
- data.tar.gz: 47804d66800ff4df491f3b7037ba5e82c131fbaf4c9eb8442a650ed6ecbf1772483d2ae2237c5437cb8ffefca831d107744a3ffacb2c517cb65a161063bc6d0e
6
+ metadata.gz: a71b99737d5b1f09b1fe76ffa1b5710c50f6330d1222d107bdfd685c966f6c4681040a688d29df8427b5cf3e91e1d29c458d00085de110187272afb925393ebb
7
+ data.tar.gz: 6ce73b0b7e0497f084080f1d4a774c974f2acb0c94ffec9d90bdbe8326f4a1e2f28721f5b4372c7d7b71ddbe0c567490faec4eb22cf884b9723fc39cddc579f0
@@ -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.4.0] - 2026-08-23
9
+
10
+ ### Fixed
11
+
12
+ - `find_related_*` raised on `.count`. The relation selects the taggable's columns plus an aliased
13
+ count, and Active Record folded that select list into the `COUNT()` it built. It now counts the
14
+ grouped query as a subquery, so `.count` and `.size` return the number of related records.
15
+
16
+ - `find_related_*` raised on PostgreSQL for any taggable model with a `json` column -- the query
17
+ grouped by every column on the table, and `json` has no equality operator so it cannot appear in
18
+ a `GROUP BY`. It now groups by the primary key on every adapter, which is what the other adapters
19
+ already did. `jsonb` was never affected, so the column type an application picked decided whether
20
+ the feature worked at all.
21
+
22
+ - `TagList` could not be serialised to YAML. Every list carried its parser in an instance variable,
23
+ and that variable holds a Class, which Psych refuses to dump. Anything serialising a model's
24
+ attributes hit it -- change-history gems, Active Job arguments, `serialize` columns. The parser is
25
+ no longer stored; the reader falls back to the configured `MakeTaggable.default_parser`. One
26
+ consequence: a list now follows the configured parser rather than the one in force when it was
27
+ built.
28
+
29
+ - `TagList#remove` ignored a symbol. `remove(:foo)` silently did nothing where `remove("foo")`
30
+ worked.
31
+
32
+ - A tag that failed validation was reported as `Tag can't be blank`, naming the wrong attribute for
33
+ the wrong reason -- reachable with no customisation, since the shipped `Tag` validates name length
34
+ at 255 characters. The error now carries the tag itself, so it says what was actually wrong and
35
+ `error.record` is the tag. `save!` raises; `save` still returns `false` and writes nothing.
36
+
37
+ - Saving a taggable read every tagging on the record, on every save, before establishing whether
38
+ any tag list had been assigned. That cost a query per save and made the gem unusable under
39
+ `strict_loading` -- a record whose tags were never touched still raised
40
+ `StrictLoadingViolationError`. Saves now consider only the contexts held in memory.
41
+
42
+ ### Changed
43
+
44
+ - **Tag lists are no longer declared as Active Model attributes.** The declaration is what gave them
45
+ dirty tracking, and it also made Active Record treat them as columns where they are not. The dirty
46
+ API is unchanged -- `tag_list_changed?`, `_was`, `_change`, `will_save_change_to_*`,
47
+ `saved_change_to_*`, and the tag lists still appear in `changes` -- but it now runs on the gem's
48
+ own bookkeeping.
49
+
50
+ Three visible consequences, each of them the point:
51
+
52
+ - `as_json` no longer includes tag lists, so serialising a collection no longer queries once per
53
+ record. Serialising three records went from 16 queries to 1. Ask for a list explicitly with
54
+ `as_json(methods: :tag_list)`.
55
+ - `attributes` no longer carries a `tag_list` key. It previously held `nil` while `tag_list`
56
+ returned the tags.
57
+ - `upsert_all` still refuses a tag list -- it writes columns, and a tag list means writing
58
+ taggings -- but `as_json` output can now be round-tripped through it, which it could not before.
59
+
60
+ `MakeTaggable::Taggable::TagListType` is removed. It backed the attribute and has no other caller.
61
+
62
+ ### Fixed
63
+
64
+ - `tag_ids = []` silently did nothing once `as_json` had been called on the record.
65
+
66
+ - Mutating a list in place -- `record.tag_list.add("x")` -- did not mark the record as changed, so
67
+ `tag_list_changed?` stayed false and anything conditioned on it never ran. The list still saved;
68
+ what was wrong was everything that asks the record what changed.
69
+
70
+ ## [1.3.0] - 2026-08-23
71
+
72
+ ### Changed
73
+
74
+ - **`tagged_with` no longer joins the taggings table.** It tests for each tag with an `EXISTS`
75
+ subquery instead, which is what stops a record being returned once per matching tagging. A tag
76
+ applied in two contexts, or a `:wild` pattern matching two of a record's tags, returned that
77
+ record twice; `.count` disagreed with the number of records, and pagination pages ran short.
78
+
79
+ The consequence for callers is that a `taggings` column is no longer in scope on the relation, so
80
+ `tagged_with("x").order("taggings.created_at")` or a `group` on a taggings column now needs an
81
+ explicit `.joins(:taggings)`. See [docs/querying.md](docs/querying.md).
82
+
83
+ Matching a dozen tags now produces a dozen correlated subqueries rather than a dozen joins.
84
+ `:match_all` is unchanged and keeps its join.
85
+
86
+ ### Fixed
87
+
88
+ - `tagged_with(..., any: true)` forced `SELECT taggable_models.*` onto the relation. That made
89
+ `.count` emit `COUNT("table".*)`, which no adapter accepts, and left a caller's own `select`
90
+ appended after the star rather than replacing it -- so every column came back regardless, and the
91
+ relation could not be used inside a `merge`. The strategy filters with an `EXISTS` subquery and
92
+ joins nothing, so Active Record's default select list was already right.
93
+
94
+ - `:order_by_matching_tag_count` raised on the default all-tags path, and the expression behind it
95
+ was invalid SQL that would have ordered nothing even where it parsed. Both strategies now share
96
+ the correlated count the `:any` path has always used, so the option orders correctly on either.
97
+ It still has no effect alongside `:match_all`.
98
+
99
+ - `tagged_with(..., exclude: true)` ignored `:start_at` and `:end_at`, excluding records on the
100
+ strength of taggings from outside the window entirely.
101
+
8
102
  ## [1.2.1] - 2026-08-23
9
103
 
10
104
  ### Fixed
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
@@ -156,3 +156,20 @@ or set `MakeTaggable.strict_case_match = true` so the behaviour is at least cons
156
156
 
157
157
  `name` is validated at 255 characters, and the column is a `string`. Tags longer than that fail
158
158
  validation rather than being truncated.
159
+
160
+ A tag that cannot be saved fails the save of the record being tagged. `save!` raises
161
+ `ActiveRecord::RecordInvalid` carrying the **tag**, so the error says what was wrong with it and
162
+ `error.record` is the tag itself; `save` returns `false` and writes nothing.
163
+
164
+ The same applies to a `Tag` subclass with validations of its own:
165
+
166
+ ```ruby
167
+ class PickyTag < MakeTaggable::Tag
168
+ validate { errors.add(:name, "must not be rude") if name.to_s.include?("rude") }
169
+ end
170
+
171
+ article.save!
172
+ # => ActiveRecord::RecordInvalid: Validation failed: Name must not be rude
173
+ ```
174
+
175
+ See [contexts.md](contexts.md) for wiring a `Tag` subclass to a context.
data/docs/querying.md CHANGED
@@ -30,9 +30,9 @@ empty relation rather than every record — worth knowing when the tags come fro
30
30
  | `:wild` | Match tags *containing* the given text, i.e. `%sci%` |
31
31
  | `:on` | Restrict to one context. Honoured by every option, `:exclude` included |
32
32
  | `:owned_by` | Restrict to tags applied by one tagger |
33
- | `:order_by_matching_tag_count` | With `:any`, order by how many tags matched, most first |
34
- | `:start_at` | Only tags applied after this time |
35
- | `:end_at` | Only tags applied before this time |
33
+ | `:order_by_matching_tag_count` | Order by how many matching taggings a record has, most first. No effect with `:match_all` |
34
+ | `:start_at` | Only tags applied after this time. Honoured by every option, `:exclude` included |
35
+ | `:end_at` | Only tags applied before this time. Honoured by every option, `:exclude` included |
36
36
 
37
37
  An empty tag list means "nothing matches" for the matching options and "nothing is ruled out" for
38
38
  `:exclude`, so the two always partition the scope between them:
@@ -162,10 +162,27 @@ MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
162
162
  query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
163
163
  scopes will be wrong.
164
164
 
165
+ ## Grouping and ordering by tagging columns
166
+
167
+ `tagged_with` does not join the taggings table, so a `taggings` column is not in scope on the
168
+ relation it returns. Join it yourself when you need one:
169
+
170
+ ```ruby
171
+ Book.tagged_with("sci-fi").joins(:taggings).group("taggings.context").count
172
+ Book.tagged_with("sci-fi").joins(:taggings).order("taggings.created_at desc")
173
+ ```
174
+
175
+ Note that joining reintroduces one row per tagging, which is exactly what `tagged_with` avoids on
176
+ its own — add `.distinct` if you want records back rather than matches.
177
+
165
178
  ## Performance notes
166
179
 
167
- - `tagged_with` with several tags and no `:any` adds one join per tag. Matching a dozen tags in a
168
- single call generates a dozen joins; prefer `any: true` where the semantics allow it.
180
+ - `tagged_with` tests for each tag with an `EXISTS` subquery, one per tag, and joins nothing. That
181
+ is what stops a record being returned once per matching tagging. Matching a dozen tags produces a
182
+ dozen correlated subqueries rather than a dozen joins, which most planners handle better, but it
183
+ is still worth reaching for `any: true` where the semantics allow it.
184
+ - Saving a taggable reads nothing from the taggings table unless a tag list was actually assigned,
185
+ so a save that touches no tags costs no extra query and works under `strict_loading`.
169
186
  - `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
170
187
  page of results and expensive across a whole table.
171
188
  - `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
@@ -145,9 +168,9 @@ module MakeTaggable
145
168
  options = args.last.is_a?(Hash) ? args.pop : {}
146
169
  options.assert_valid_keys :parse, :parser
147
170
 
148
- parser = options[:parser] || @parser
171
+ chosen_parser = options[:parser] || parser
149
172
 
150
- args.map! { |a| parser.new(a).parse } if options[:parse] || options[:parser]
173
+ args.map! { |a| chosen_parser.new(a).parse } if options[:parse] || options[:parser]
151
174
 
152
175
  args.flatten!
153
176
  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
@@ -123,8 +135,8 @@ module MakeTaggable::Taggable
123
135
  # @option options [TrueClass, FalseClass] :exclude match records carrying none of the tags
124
136
  # @option options [TrueClass, FalseClass] :match_all match records carrying only these tags
125
137
  # @option options [TrueClass, FalseClass] :wild match tags containing the given text
126
- # @option options [TrueClass, FalseClass] :order_by_matching_tag_count with `:any`, order by
127
- # how many tags matched, most first
138
+ # @option options [TrueClass, FalseClass] :order_by_matching_tag_count order by how many
139
+ # matching taggings a record has, most first. No effect alongside `:match_all`
128
140
  # @option options [ActiveRecord::Base] :owned_by only tags applied by this tagger
129
141
  # @option options [Symbol, String] :on only tags applied in this context
130
142
  # @option options [Time, Date] :start_at only tags applied after this time
@@ -192,7 +204,13 @@ module MakeTaggable::Taggable
192
204
  # @return [Array<String>, NilClass]
193
205
  #
194
206
  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)
207
+ # The declared contexts are checked first deliberately. Ruby evaluates the
208
+ # left operand of `||` first, so testing custom_contexts up front loaded
209
+ # every tagging on the record just to assign a list on an ordinary
210
+ # declared context.
211
+ return if self.class.tag_types.map(&:to_s).include?(value.to_s)
212
+
213
+ custom_contexts << value.to_s unless custom_contexts.include?(value.to_s)
196
214
  end
197
215
 
198
216
  ##
@@ -216,6 +234,151 @@ module MakeTaggable::Taggable
216
234
  instance_variable_defined?(variable_name) && instance_variable_get(variable_name)
217
235
  end
218
236
 
237
+ ##
238
+ # The tag lists as they stood when the record was loaded or last saved, keyed by context.
239
+ #
240
+ # @return [Hash{String => Array<String>}]
241
+ #
242
+ # @api private
243
+ #
244
+ def original_tag_lists
245
+ @original_tag_lists ||= {}
246
+ end
247
+
248
+ ##
249
+ # Records what a context's list looked like before anything touched it, if that has not been
250
+ # noted already. Called before every change, so the first note wins and later ones are ignored.
251
+ #
252
+ # @param context [Symbol, String] the tagging context
253
+ # @return [void]
254
+ #
255
+ # @api private
256
+ #
257
+ def note_tag_list_original(context)
258
+ key = context.to_s
259
+ return if original_tag_lists.key?(key)
260
+
261
+ original_tag_lists[key] = tag_list_cache_on(key).to_a.dup
262
+ end
263
+
264
+ ##
265
+ # Notes the original list and drops the cached one, after a tagging was added or removed
266
+ # through the association rather than through a list.
267
+ #
268
+ # The cached list is what `tag_list_on` answers from, and pushing onto `record.tags` does not
269
+ # go near it -- so without dropping it the list before and after compare equal and nothing
270
+ # looks changed.
271
+ #
272
+ # @param context [Symbol, String] the tagging context
273
+ # @return [void]
274
+ #
275
+ # @api private
276
+ #
277
+ def tag_list_changed_by_association(context)
278
+ note_tag_list_original(context)
279
+
280
+ singular = context.to_s.singularize
281
+ instance_variable_set("@#{singular}_list", nil)
282
+ instance_variable_set("@all_#{singular}_list", nil)
283
+ end
284
+
285
+ ##
286
+ # Whether a context's list differs from the one loaded or last saved.
287
+ #
288
+ # Order counts only where the model asked for it with `make_ordered_taggable`.
289
+ #
290
+ # @param context [Symbol, String] the tagging context
291
+ # @return [TrueClass, FalseClass]
292
+ #
293
+ def tag_list_changed_on?(context)
294
+ key = context.to_s
295
+ return false unless original_tag_lists.key?(key)
296
+
297
+ comparable_tag_list(original_tag_lists[key]) != comparable_tag_list(tag_list_on(key))
298
+ end
299
+
300
+ ##
301
+ # A context's list as it stood when the record was loaded or last saved.
302
+ #
303
+ # @param context [Symbol, String] the tagging context
304
+ # @return [Array<String>]
305
+ #
306
+ def tag_list_was_on(context)
307
+ key = context.to_s
308
+
309
+ original_tag_lists.key?(key) ? original_tag_lists[key] : tag_list_on(key).to_a
310
+ end
311
+
312
+ ##
313
+ # A context's list before and after, or `nil` when it has not changed.
314
+ #
315
+ # @param context [Symbol, String] the tagging context
316
+ # @return [Array<Array<String>>, NilClass]
317
+ #
318
+ def tag_list_change_on(context)
319
+ return unless tag_list_changed_on?(context)
320
+
321
+ [tag_list_was_on(context), tag_list_on(context).to_a]
322
+ end
323
+
324
+ ##
325
+ # The tag list changes this record is carrying, keyed the way Active Model keys `changes`.
326
+ #
327
+ # @return [Hash{String => Array<Array<String>>}]
328
+ #
329
+ # @api private
330
+ #
331
+ def tag_list_changes
332
+ # assigned_tagging_contexts, not tagging_contexts: the latter reads the
333
+ # taggings table to find contexts used previously, and a record only has
334
+ # pending changes in contexts it holds a list for.
335
+ assigned_tagging_contexts.each_with_object({}) do |context, changes|
336
+ change = tag_list_change_on(context)
337
+ changes["#{context.to_s.singularize}_list"] = change if change
338
+ end
339
+ end
340
+
341
+ ##
342
+ # The tag list changes written by the most recent save.
343
+ #
344
+ # @return [Hash{String => Array<Array<String>>}]
345
+ #
346
+ # @api private
347
+ #
348
+ def saved_tag_list_changes
349
+ @saved_tag_list_changes ||= {}
350
+ end
351
+
352
+ ##
353
+ # Active Model's changes, plus the tag lists.
354
+ #
355
+ # @return [ActiveSupport::HashWithIndifferentAccess]
356
+ #
357
+ def changes
358
+ super.merge(tag_list_changes)
359
+ end
360
+
361
+ ##
362
+ # @return [Hash] the previous values of everything changed, tag lists included
363
+ #
364
+ def changed_attributes
365
+ super.merge(tag_list_changes.transform_values(&:first))
366
+ end
367
+
368
+ ##
369
+ # @return [TrueClass, FalseClass] whether anything changed, tag lists included
370
+ #
371
+ def changed?
372
+ super || tag_list_changes.any?
373
+ end
374
+
375
+ ##
376
+ # @return [Hash] the changes the last save wrote, tag lists included
377
+ #
378
+ def saved_changes
379
+ super.merge(saved_tag_list_changes)
380
+ end
381
+
219
382
  ##
220
383
  # A context's tag list, loading it from the caching column or the database as needed.
221
384
  #
@@ -224,13 +387,22 @@ module MakeTaggable::Taggable
224
387
  #
225
388
  def tag_list_cache_on(context)
226
389
  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
390
+ return instance_variable_get(variable_name) if instance_variable_get(variable_name)
391
+
392
+ list =
393
+ if cached_tag_list_on(context) && ensure_included_cache_methods! && self.class.caching_tag_list_on?(context)
394
+ MakeTaggable.default_parser.new(cached_tag_list_on(context)).parse
395
+ else
396
+ MakeTaggable::TagList.new(tags_on(context).map(&:name))
397
+ end
398
+
399
+ # Note what was there the first time the list is built, before anything
400
+ # can have touched it. A caller holding this list can mutate it in place
401
+ # -- tag_list.add("x") -- which never goes through the writer, so this is
402
+ # the only chance to see the original.
403
+ original_tag_lists[context.to_s] ||= list.to_a.dup
404
+
405
+ instance_variable_set(variable_name, list)
234
406
  end
235
407
 
236
408
  ##
@@ -303,6 +475,10 @@ module MakeTaggable::Taggable
303
475
  def set_tag_list_on(context, new_list)
304
476
  add_custom_context(context)
305
477
 
478
+ # Before the list is replaced, so the note captures what was there rather
479
+ # than what is being put there.
480
+ note_tag_list_original(context)
481
+
306
482
  variable_name = "@#{context.to_s.singularize}_list"
307
483
 
308
484
  parsed_new_list = MakeTaggable.default_parser.new(new_list).parse
@@ -319,6 +495,23 @@ module MakeTaggable::Taggable
319
495
  self.class.tag_types.map(&:to_s) + custom_contexts
320
496
  end
321
497
 
498
+ ##
499
+ # The contexts a save has to consider: the declared ones, plus any context this record has
500
+ # been handed a list for in memory.
501
+ #
502
+ # Deliberately not {#tagging_contexts}, which reads the taggings table to find contexts used
503
+ # previously. A save only writes lists held in memory, so the ones already loaded are the only
504
+ # ones that can have anything to write -- and reading the table on every save cost a query
505
+ # whether or not any tag changed, and broke `strict_loading` outright.
506
+ #
507
+ # @return [Array<String>]
508
+ #
509
+ # @api private
510
+ #
511
+ def assigned_tagging_contexts
512
+ self.class.tag_types.map(&:to_s) + (@custom_contexts || [])
513
+ end
514
+
322
515
  ##
323
516
  # Reloads the record, discarding the tag lists held in memory.
324
517
  #
@@ -346,7 +539,7 @@ module MakeTaggable::Taggable
346
539
  # @return [TrueClass]
347
540
  #
348
541
  def save_tags
349
- tagging_contexts.each do |context|
542
+ assigned_tagging_contexts.each do |context|
350
543
  next unless tag_list_cache_set_on(context)
351
544
 
352
545
  # List of currently assigned tag names
@@ -355,6 +548,13 @@ module MakeTaggable::Taggable
355
548
  # Find existing tags or create non-existing tags:
356
549
  tags = find_or_create_tags_from_list_with_context(tag_list, context)
357
550
 
551
+ # A tag that failed its own validation comes back unsaved, with a nil
552
+ # id. Left alone it reaches taggings.create! as `tag_id: nil`, and the
553
+ # caller is told "Tag can't be blank" -- the wrong attribute, and no
554
+ # sign of what was actually wrong. Report the tag itself instead.
555
+ unsaved = tags.reject(&:persisted?)
556
+ raise ActiveRecord::RecordInvalid.new(unsaved.first) if unsaved.any?
557
+
358
558
  # Tag objects for currently assigned tags
359
559
  current_tags = tags_on(context)
360
560
 
@@ -393,25 +593,27 @@ module MakeTaggable::Taggable
393
593
  end
394
594
  end
395
595
 
596
+ settle_tag_list_changes
597
+
396
598
  true
397
599
  end
398
600
 
399
- private
400
-
401
- def ensure_included_cache_methods!
402
- self.class.columns
601
+ # Moves the pending tag list changes into the saved ones, so that after a
602
+ # save the record reports what the save wrote rather than what it was about
603
+ # to write. Mirrors what Active Model does for real attributes.
604
+ def settle_tag_list_changes
605
+ @saved_tag_list_changes = tag_list_changes
606
+ original_tag_lists.clear
403
607
  end
404
608
 
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 }
609
+ private
610
+
611
+ def comparable_tag_list(list)
612
+ self.class.preserve_tag_order? ? list.to_a : list.to_a.sort
409
613
  end
410
614
 
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 }
615
+ def ensure_included_cache_methods!
616
+ self.class.columns
415
617
  end
416
618
 
417
619
  ##
@@ -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)
@@ -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
@@ -11,63 +11,72 @@ module MakeTaggable::Taggable::TaggedWithQuery
11
11
  # @return [ActiveRecord::Relation]
12
12
  #
13
13
  def build
14
- taggable_model.joins(each_tag_in_list)
14
+ taggable_model.joins(match_all_join)
15
+ .where(carries_every_tag)
15
16
  .group(by_taggable)
16
17
  .having(tags_that_matches_count)
17
- .order(order_conditions)
18
+ .order(Arel.sql(order_conditions))
18
19
  .readonly(false)
19
20
  end
20
21
 
21
22
  private
22
23
 
23
- def each_tag_in_list
24
- arel_join = taggable_arel_table
25
-
26
- tag_list.each do |tag|
27
- tagging_alias = tagging_arel_table.alias(tagging_alias(tag))
28
- arel_join = arel_join
29
- .join(tagging_alias)
30
- .on(on_conditions(tag, tagging_alias))
31
- end
32
-
33
- if options[:match_all].present?
34
- arel_join = arel_join
35
- .join(tagging_arel_table, Arel::Nodes::OuterJoin)
36
- .on(
37
- match_all_on_conditions
38
- )
39
- end
24
+ # One EXISTS test per tag, rather than one join per tag.
25
+ #
26
+ # A join multiplies the result: a record is returned once for every tagging
27
+ # that satisfies it, so a tag applied in two contexts, or a wild pattern
28
+ # matching two of a record's tags, returned that record twice. EXISTS asks
29
+ # the question the query is actually asking -- does this record carry the
30
+ # tag -- and answers it once.
31
+ def carries_every_tag
32
+ tag_list.map { |tag| taggings_for(tag).exists }.inject(:and)
33
+ end
40
34
 
41
- arel_join.join_sources
35
+ def taggings_for(tag)
36
+ tagging_arel_table
37
+ .project(Arel.star)
38
+ .where(tag_conditions(tag))
42
39
  end
43
40
 
44
- def on_conditions(tag, tagging_alias)
45
- on_condition = tagging_alias[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
46
- .and(tagging_alias[:taggable_type].eq(taggable_model.base_class.name))
41
+ def tag_conditions(tag)
42
+ condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
43
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
47
44
  .and(
48
- tagging_alias[:tag_id].in(
45
+ tagging_arel_table[:tag_id].in(
49
46
  tag_arel_table.project(tag_arel_table[:id]).where(tag_match_type(tag))
50
47
  )
51
48
  )
52
49
 
53
50
  if options[:start_at].present?
54
- on_condition = on_condition.and(tagging_alias[:created_at].gteq(options[:start_at]))
51
+ condition = condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
55
52
  end
56
53
 
57
54
  if options[:end_at].present?
58
- on_condition = on_condition.and(tagging_alias[:created_at].lteq(options[:end_at]))
55
+ condition = condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
59
56
  end
60
57
 
61
58
  if options[:on].present?
62
- on_condition = on_condition.and(tagging_alias[:context].eq(options[:on]))
59
+ condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
63
60
  end
64
61
 
65
62
  if (owner = options[:owned_by]).present?
66
- on_condition = on_condition.and(tagging_alias[:tagger_id].eq(owner.id))
67
- .and(tagging_alias[:tagger_type].eq(owner.class.base_class.to_s))
63
+ condition = condition.and(tagging_arel_table[:tagger_id].eq(owner.id))
64
+ .and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
68
65
  end
69
66
 
70
- on_condition
67
+ condition
68
+ end
69
+
70
+ # :match_all keeps its outer join. It counts a record's taggings and compares
71
+ # that to the number of tags matched, so it needs them joined -- and the
72
+ # GROUP BY it already carries collapses the duplicates a join would cause.
73
+ def match_all_join
74
+ return [] unless options[:match_all].present?
75
+
76
+ taggable_arel_table
77
+ .join(tagging_arel_table, Arel::Nodes::OuterJoin)
78
+ .on(match_all_on_conditions)
79
+ .join_sources
71
80
  end
72
81
 
73
82
  def match_all_on_conditions
@@ -107,15 +116,17 @@ module MakeTaggable::Taggable::TaggedWithQuery
107
116
 
108
117
  def order_conditions
109
118
  order_by = []
110
- order_by << tagging_arel_table.project(tagging_arel_table[Arel.star].count.as("taggings_count")).order("taggings_count DESC").to_sql if options[:order_by_matching_tag_count].present? && options[:match_all].blank?
119
+
120
+ # The old expression here counted every tagging in the table, correlated
121
+ # to nothing, and asked for COUNT(taggings.*) while doing it -- invalid
122
+ # SQL that ordered nothing even where it parsed. The shared correlated
123
+ # count is what the :any strategy has always used.
124
+ if options[:order_by_matching_tag_count].present? && options[:match_all].blank?
125
+ order_by << matching_tag_count_order
126
+ end
111
127
 
112
128
  order_by << options[:order] if options[:order].present?
113
129
  order_by.join(", ")
114
130
  end
115
-
116
- def tagging_alias(tag)
117
- alias_base_name = taggable_model.base_class.name.downcase
118
- adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{MakeTaggable::Utils.sha_prefix(tag)}")
119
- end
120
131
  end
121
132
  end
@@ -11,55 +11,26 @@ module MakeTaggable::Taggable::TaggedWithQuery
11
11
  # @return [ActiveRecord::Relation]
12
12
  #
13
13
  def build
14
- taggable_model.select(all_fields)
15
- .where(model_has_at_least_one_tag)
14
+ # No select of our own. This strategy filters with an EXISTS subquery and
15
+ # joins nothing, so Active Record's default select list is already right.
16
+ # Forcing `taggable_models.*` made COUNT() invalid and left a caller's own
17
+ # select appended after the star rather than replacing it.
18
+ taggable_model
19
+ .where(model_has_matching_taggings)
16
20
  .order(Arel.sql(order_conditions))
17
21
  .readonly(false)
18
22
  end
19
23
 
20
24
  private
21
25
 
22
- def all_fields
23
- taggable_arel_table[Arel.star]
24
- end
25
-
26
- def model_has_at_least_one_tag
27
- tagging_arel_table.project(Arel.star).where(at_least_one_tag).exists
28
- end
29
-
30
- def at_least_one_tag
31
- exists_contition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
32
- .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
33
- .and(
34
- tagging_arel_table[:tag_id].in(
35
- tag_arel_table.project(tag_arel_table[:id]).where(tags_match_type)
36
- )
37
- )
38
-
39
- if options[:start_at].present?
40
- exists_contition = exists_contition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
41
- end
42
-
43
- if options[:end_at].present?
44
- exists_contition = exists_contition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
45
- end
46
-
47
- if options[:on].present?
48
- exists_contition = exists_contition.and(tagging_arel_table[:context].eq(options[:on]))
49
- end
50
-
51
- if (owner = options[:owned_by]).present?
52
- exists_contition = exists_contition.and(tagging_arel_table[:tagger_id].eq(owner.id))
53
- .and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
54
- end
55
-
56
- exists_contition
26
+ def model_has_matching_taggings
27
+ tagging_arel_table.project(Arel.star).where(matching_taggings).exists
57
28
  end
58
29
 
59
30
  def order_conditions
60
31
  order_by = []
61
32
  if options[:order_by_matching_tag_count].present?
62
- order_by << "(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{at_least_one_tag.to_sql}) desc"
33
+ order_by << matching_tag_count_order
63
34
  end
64
35
 
65
36
  order_by << options[:order] if options[:order].present?
@@ -24,20 +24,27 @@ module MakeTaggable::Taggable::TaggedWithQuery
24
24
  .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
25
25
  .and(tags_match_type)
26
26
 
27
- # Without this the subquery gathers taggings from every context, so a
28
- # record tagged in one context is excluded from a query about another.
27
+ # Every option below narrows which taggings count as "carrying the tag".
28
+ # Left off, the subquery gathers taggings from other contexts and other
29
+ # times, and excludes records on the strength of them.
29
30
  if options[:on].present?
30
31
  on_condition = on_condition.and(tagging_arel_table[:context].eq(options[:on]))
31
32
  end
32
33
 
34
+ if options[:start_at].present?
35
+ on_condition = on_condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
36
+ end
37
+
38
+ if options[:end_at].present?
39
+ on_condition = on_condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
40
+ end
41
+
33
42
  taggable_arel_table[taggable_model.primary_key].not_in(
34
43
  tagging_arel_table
35
44
  .project(tagging_arel_table[:taggable_id])
36
45
  .join(tag_arel_table)
37
46
  .on(on_condition)
38
47
  )
39
-
40
- # FIXME: missing time scope, this is also missing in the original implementation
41
48
  end
42
49
 
43
50
  def owning_to_tagger
@@ -61,6 +61,46 @@ module MakeTaggable::Taggable::TaggedWithQuery
61
61
  end
62
62
  end
63
63
 
64
+ # A condition selecting the taggings that tie a row of the taggable table to
65
+ # one of the tags being matched, narrowed by whichever of :on, :owned_by,
66
+ # :start_at and :end_at were given.
67
+ #
68
+ # It correlates to the taggable table, so it only means anything inside a
69
+ # subquery -- an EXISTS test, or a COUNT used for ordering.
70
+ def matching_taggings
71
+ condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])
72
+ .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name))
73
+ .and(
74
+ tagging_arel_table[:tag_id].in(
75
+ tag_arel_table.project(tag_arel_table[:id]).where(tags_match_type)
76
+ )
77
+ )
78
+
79
+ if options[:start_at].present?
80
+ condition = condition.and(tagging_arel_table[:created_at].gteq(options[:start_at]))
81
+ end
82
+
83
+ if options[:end_at].present?
84
+ condition = condition.and(tagging_arel_table[:created_at].lteq(options[:end_at]))
85
+ end
86
+
87
+ if options[:on].present?
88
+ condition = condition.and(tagging_arel_table[:context].eq(options[:on]))
89
+ end
90
+
91
+ if (owner = options[:owned_by]).present?
92
+ condition = condition.and(tagging_arel_table[:tagger_id].eq(owner.id))
93
+ .and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s))
94
+ end
95
+
96
+ condition
97
+ end
98
+
99
+ # Orders by how many of the matched taggings a row has, most first.
100
+ def matching_tag_count_order
101
+ "(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{matching_taggings.to_sql}) desc"
102
+ end
103
+
64
104
  def escaped_tag(tag)
65
105
  tag = tag.downcase unless MakeTaggable.strict_case_match
66
106
  MakeTaggable::Utils.escape_like(tag)
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.2.1"
9
+ VERSION = "1.4.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
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.2.1
4
+ version: 1.4.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