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 +4 -4
- data/AATO_ISSUE_TRIAGE.md +122 -0
- data/AATO_UPSTREAM_COMMIT_REVIEW.md +74 -0
- data/CHANGELOG.md +94 -0
- data/docs/configuration.md +10 -0
- data/docs/contexts.md +29 -5
- data/docs/database.md +36 -2
- data/docs/querying.md +17 -1
- data/lib/make_taggable/tag_list.rb +32 -5
- data/lib/make_taggable/taggable/core.rb +266 -39
- data/lib/make_taggable/taggable/ownership.rb +5 -2
- data/lib/make_taggable/taggable/related.rb +44 -5
- data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +2 -2
- data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +2 -2
- data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +29 -5
- data/lib/make_taggable/version.rb +1 -1
- data/lib/make_taggable.rb +33 -10
- metadata +3 -2
- data/lib/make_taggable/taggable/tag_list_type.rb +0 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d900a8ac37e0f88ead6c0516fd30ddee5e8cc11c893b7d64e85c090a8b59598
|
|
4
|
+
data.tar.gz: b364eef565e3a171c56558f928360de5230c445b4b210e1aab8f536f9b998c70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/configuration.md
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
|
|
33
|
+
`skill_list` takes part in dirty tracking:
|
|
34
34
|
|
|
35
35
|
```ruby
|
|
36
36
|
user.skill_list = "diving"
|
|
37
|
-
user.skill_list_changed?
|
|
38
|
-
user.skill_list_was
|
|
39
|
-
user.skill_list_change
|
|
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
|
-
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
chosen_parser = options[:parser] || parser
|
|
149
176
|
|
|
150
|
-
args.map! { |a|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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)
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
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
|