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 +4 -4
- data/AATO_ISSUE_TRIAGE.md +122 -0
- data/AATO_UPSTREAM_COMMIT_REVIEW.md +74 -0
- data/CHANGELOG.md +94 -0
- data/docs/contexts.md +29 -5
- data/docs/database.md +17 -0
- data/docs/querying.md +22 -5
- data/lib/make_taggable/tag_list.rb +27 -4
- data/lib/make_taggable/taggable/core.rb +238 -36
- data/lib/make_taggable/taggable/ownership.rb +1 -1
- data/lib/make_taggable/taggable/related.rb +44 -5
- data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +47 -36
- data/lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb +9 -38
- data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +11 -4
- data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +40 -0
- data/lib/make_taggable/version.rb +1 -1
- data/lib/make_taggable.rb +0 -1
- 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: 1b40ab74ad5312aa300ba26015e4fc7d5da67e5e37c5f4c7efb235aa494e28d0
|
|
4
|
+
data.tar.gz: 84db5687e59dfb33259222e47bc58fdb7d94dd35882faafc2f1ceede7effc841
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
@@ -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` |
|
|
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`
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
+
chosen_parser = options[:parser] || parser
|
|
149
172
|
|
|
150
|
-
args.map! { |a|
|
|
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
|
-
|
|
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
|
|
127
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
.and(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
.and(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
23
|
-
|
|
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 <<
|
|
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
|
-
#
|
|
28
|
-
#
|
|
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)
|
data/lib/make_taggable.rb
CHANGED
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.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
|