make_taggable 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be8e033e4d750a0bc75a77f652351843c096f38d8f8332f56e9d642e2085f12b
4
- data.tar.gz: 698531499c7e7636b241b854cf60e2e2aee3799af3dba3d2d8a5f1ae2ff954f1
3
+ metadata.gz: 7d3a7c89a48d79f6d985b8290d88007368ecdf983789a7e4881662724a1060a9
4
+ data.tar.gz: dda60e219b50e10b8948b6b9b260e703d8815f43bd200b1c434875144ae68071
5
5
  SHA512:
6
- metadata.gz: c2f772cac980b2bab6c101c087916f3083f519aaec61c8c45ad4cd8750ab95dc6f9534d4ac2a511a6411872590dcfb4a58c3af7f68611458365ff8e5ce024866
7
- data.tar.gz: 50aae450f2dfd5923ecf8c18a348b6e7e27b761d71d7fd59b61498c338341f607b735194b2a56da154624ad71e84a748f3a3aebec84beb0a6a21b3a7d0f244b0
6
+ metadata.gz: 7aa7e8b6c7190cbc8fbdcaf54fd5f49bf8f877c3161c7466e7df98eefd8f14eaa208391b47fa5a328db563d489d5e9a6f82b3bfd4b0ce770cdedbf3423f958bc
7
+ data.tar.gz: 0e868727368e57e72fe627cbbc4b7a4dc2077e459d6ac9131732788fec26a88037bd7ed12cb86a35f3b8308b3df7fa238319c24628f39e698c073e7d0dd8fa68
data/CHANGELOG.md CHANGED
@@ -5,7 +5,41 @@ 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.0.0] - unreleased
8
+ ## [1.1.1] - 2026-08-22
9
+
10
+ ### Fixed
11
+
12
+ - Documentation examples used `params.expect` and `ActiveRecord::Migration[8.0]`, neither of which
13
+ exists on Active Record 7.2 -- the version the gem promises to support. Both are corrected, and
14
+ `spec/docs_spec.rb` now fails the build if either creeps back.
15
+
16
+ ### Internal
17
+
18
+ - Added documentation checks to the suite: every Ruby block in the README and `docs/` must parse,
19
+ example migrations must declare a version the floor accepts, version-sensitive calls must be
20
+ shown alongside an alternative, and relative links must resolve. They run on every Rails version
21
+ in the matrix.
22
+
23
+ ## [1.1.0] - 2026-08-22
24
+
25
+ ### Added
26
+
27
+ - A migration adding `taggings_unowned_idx`, a partial unique index that stops duplicate unowned
28
+ taggings at the database level. `taggings_idx` never could: it spans the nullable tagger columns,
29
+ and SQL compares nulls as distinct, so only the model validation stood in the way and a validation
30
+ cannot win a race. MySQL has no partial indexes, so the migration is a no-op there.
31
+
32
+ Install it with `rails make_taggable_engine:install:migrations`. If it fails, the table already
33
+ holds duplicates -- see [docs/database.md](docs/database.md).
34
+
35
+ ### Internal
36
+
37
+ - The suite runs in random order. Examples were leaking state into each other: tagging declarations
38
+ on the shared models, and the library configuration -- one example leaving `remove_unused_tags`
39
+ on could make an unrelated example fail a foreign key check. Both are now snapshotted and restored
40
+ around every example.
41
+
42
+ ## [1.0.0] - 2026-08-22
9
43
 
10
44
  ### Breaking
11
45
 
data/CONTRIBUTING.md CHANGED
@@ -47,10 +47,16 @@ bundle exec appraisal rake
47
47
 
48
48
  ### A note on test ordering
49
49
 
50
- The suite runs in defined order. Several examples mutate shared model classes adding a context to
51
- `TaggableModel`, flipping `preserve_tag_order` and rely on siblings having run first. Randomising
52
- the order exposes this. Making those examples self-contained is welcome work; until then, please
53
- don't add new examples that depend on a sibling's side effects.
50
+ The suite runs in random order, so an example that depends on a sibling having run first will fail
51
+ sooner or later. Reproduce a failure with the seed it reports:
52
+
53
+ ```shell
54
+ bundle exec rspec --order random:12345
55
+ ```
56
+
57
+ Examples are free to change global state — the tagging declarations on the shared models, and the
58
+ `MakeTaggable` configuration — because `spec_helper.rb` snapshots both and restores them after every
59
+ example. Anything else you make global is yours to clean up.
54
60
 
55
61
  ## Documentation
56
62
 
@@ -58,6 +64,12 @@ Prose documentation lives in [docs/](docs). API documentation is YARD comments i
58
64
  the style already there: a `##` opening line, a description, a blank line, then tags with real Ruby
59
65
  types.
60
66
 
67
+ `spec/docs_spec.rb` holds the prose to the versions the gemspec promises. It checks that every Ruby
68
+ block parses, that no example declares a migration version newer than the supported floor, that
69
+ calls the floor does not have are shown alongside an alternative, and that every relative link
70
+ resolves. It runs with the rest of the suite, so it runs on every Rails version in the matrix --
71
+ including the oldest, which is where documentation written on the newest tends to break.
72
+
61
73
  Build it locally with `bundle exec yard doc`.
62
74
 
63
75
  ## Releasing
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # MakeTaggable
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/make_taggable.svg)](https://badge.fury.io/rb/make_taggable)
4
- ![CI](https://github.com/MatthewKennedy/make_taggable/workflows/CI/badge.svg)
3
+ [![Gem Version](https://img.shields.io/gem/v/make_taggable)](https://rubygems.org/gems/make_taggable)
4
+ [![Downloads](https://img.shields.io/gem/dt/make_taggable)](https://rubygems.org/gems/make_taggable)
5
+ [![CI](https://github.com/MatthewKennedy/make_taggable/actions/workflows/ci.yml/badge.svg)](https://github.com/MatthewKennedy/make_taggable/actions/workflows/ci.yml)
5
6
  [![Standard Rb](https://github.com/MatthewKennedy/make_taggable/actions/workflows/standard-ci.yml/badge.svg)](https://github.com/MatthewKennedy/make_taggable/actions/workflows/standard-ci.yml)
6
7
 
7
8
  Tagging for Active Record models, across any number of named contexts.
@@ -12,7 +13,7 @@ order they were added in, and be cached on the record for display.
12
13
 
13
14
  ## Requirements
14
15
 
15
- | | |
16
+ | Component | Supported |
16
17
  |---|---|
17
18
  | Ruby | 3.2 or newer |
18
19
  | Active Record | 7.2 or newer |
@@ -35,44 +36,73 @@ rails db:migrate
35
36
 
36
37
  ```ruby
37
38
  class Book < ApplicationRecord
38
- make_taggable # the :tags context
39
- make_taggable :genres # and one of your own
39
+ make_taggable # the :tags context, for free-form tags
40
+ make_taggable :genres # a context of your own, for a curated set
40
41
  end
41
42
  ```
42
43
 
44
+ Each context is a separate set of tags, with its own list:
45
+
43
46
  ```ruby
44
- book = Book.create!(title: "Dune", tag_list: "sci-fi, classic")
47
+ book = Book.create!(title: "Dune", genre_list: "sci-fi, classic")
48
+
49
+ book.genre_list # => ["sci-fi", "classic"]
50
+ ```
45
51
 
46
- book.tag_list # => ["sci-fi", "classic"]
47
- book.tag_list.add("desert")
48
- book.tag_list.remove("classic")
52
+ Add and remove individual tags. Nothing is written until you save:
53
+
54
+ ```ruby
55
+ book.genre_list.add("space opera")
56
+ book.genre_list.remove("classic")
49
57
  book.save
50
58
 
51
- book.genre_list = "science fiction"
59
+ book.genre_list # => ["sci-fi", "space opera"]
60
+ book.genres # => [#<MakeTaggable::Tag name: "sci-fi">, #<MakeTaggable::Tag name: "space opera">]
61
+ ```
62
+
63
+ Assigning replaces the whole list, and the two contexts never touch each other:
64
+
65
+ ```ruby
66
+ book.tag_list = "desert, chosen-one, re-read"
52
67
  book.save
68
+
69
+ book.tag_list # => ["desert", "chosen-one", "re-read"]
70
+ book.genre_list # => ["sci-fi", "space opera"]
53
71
  ```
54
72
 
55
73
  Find them again:
56
74
 
57
75
  ```ruby
58
76
  Book.tagged_with("sci-fi") # carries this tag
59
- Book.tagged_with(["sci-fi", "desert"]) # carries both
77
+ Book.tagged_with(["sci-fi", "space opera"]) # carries both
60
78
  Book.tagged_with(["sci-fi", "fantasy"], any: true) # carries either
61
- Book.tagged_with(["sci-fi"], exclude: true) # carries neither
79
+ Book.tagged_with(["fantasy"], exclude: true) # carries neither
80
+ ```
81
+
82
+ Scope a query to one context, and tags in the others stop counting:
83
+
84
+ ```ruby
85
+ Book.tagged_with("sci-fi", on: :genres) # => [#<Book title: "Dune">]
86
+ Book.tagged_with("sci-fi", on: :tags) # => []
87
+ ```
62
88
 
63
- Book.tag_counts_on(:genres) # tags with usage counts
89
+ Counts, for tag clouds and "most used" lists:
90
+
91
+ ```ruby
92
+ Book.tag_counts_on(:genres) # tags carrying a `count`
64
93
  Book.top_genres(10)
65
94
  ```
66
95
 
67
96
  Tags are ordinary attributes as far as your controller is concerned:
68
97
 
69
98
  ```ruby
70
- params.expect(book: [:title, :tag_list])
99
+ params.expect(book: [:title, :tag_list]) # Rails 8
100
+ params.require(:book).permit(:title, :tag_list) # Rails 7.2
71
101
  ```
72
102
 
73
103
  ## Documentation
74
104
 
75
- | | |
105
+ | Guide | Covers |
76
106
  |---|---|
77
107
  | [Getting started](docs/getting-started.md) | Install, first tagged model, reading and writing |
78
108
  | [Tag contexts](docs/contexts.md) | Multiple contexts, ordered tags, contexts created at runtime |
data/UPGRADING.md CHANGED
@@ -23,6 +23,10 @@ This release renames the declaration methods and changes how delimiters are esca
23
23
 
24
24
  4. Requirements are now Ruby 3.2 and Active Record 7.2 or newer.
25
25
 
26
+ Note for anyone upgrading past 1.0: a later migration adds a unique index
27
+ preventing duplicate unowned taggings. If it fails, your taggings table
28
+ already holds duplicates -- docs/database.md has a snippet to clear them.
29
+
26
30
  Install any new migrations:
27
31
 
28
32
  rails make_taggable_engine:install:migrations
@@ -0,0 +1,18 @@
1
+ class AddUnownedTaggingsUniqueIndex < ActiveRecord::Migration[7.2]
2
+ # taggings_idx spans tagger_id and tagger_type, which are NULL on every
3
+ # tagging nobody owns. SQL compares NULLs as distinct, so that index does not
4
+ # stop two identical unowned taggings -- only the model validation does, and a
5
+ # validation cannot win a race between two concurrent writes.
6
+ #
7
+ # A partial index closes it. MySQL has no partial indexes, so it keeps the
8
+ # validation on its own.
9
+ def change
10
+ return if MakeTaggable::Utils.using_mysql?
11
+
12
+ add_index MakeTaggable.taggings_table,
13
+ [:tag_id, :taggable_id, :taggable_type, :context],
14
+ unique: true,
15
+ where: "tagger_id IS NULL",
16
+ name: "taggings_unowned_idx"
17
+ end
18
+ end
data/docs/caching.md CHANGED
@@ -10,7 +10,7 @@ There is no setting. Add a column named `cached_<singular context>_list` and cac
10
10
  itself on for that context:
11
11
 
12
12
  ```ruby
13
- class AddCachedTagListToBooks < ActiveRecord::Migration[8.0]
13
+ class AddCachedTagListToBooks < ActiveRecord::Migration[7.2]
14
14
  def change
15
15
  add_column :books, :cached_tag_list, :string
16
16
  add_column :books, :cached_genre_list, :string
data/docs/contexts.md CHANGED
@@ -46,10 +46,15 @@ Calling `make_taggable` again adds contexts rather than replacing them, which is
46
46
  subclass extend its parent:
47
47
 
48
48
  ```ruby
49
+ class Book < ApplicationRecord
50
+ make_taggable
51
+ end
52
+
49
53
  class Manual < Book
50
54
  make_taggable :audiences
51
55
  end
52
56
 
57
+ Book.tag_types # => [:tags]
53
58
  Manual.tag_types # => [:tags, :audiences]
54
59
  ```
55
60
 
@@ -134,7 +139,7 @@ tags, `Market.count` returns every tag in the table, and reloading a record give
134
139
  `MakeTaggable::Tag` back. Add the column to get real separation:
135
140
 
136
141
  ```ruby
137
- class AddTypeToTags < ActiveRecord::Migration[8.0]
142
+ class AddTypeToTags < ActiveRecord::Migration[7.2]
138
143
  def change
139
144
  add_column MakeTaggable.tags_table, :type, :string
140
145
  add_index MakeTaggable.tags_table, :type
data/docs/database.md CHANGED
@@ -34,22 +34,39 @@ That suits read-heavy tagging. If your application writes taggings in bulk, the
34
34
  looking at — every index is maintained on insert, and several of the standalone ones are prefixes of
35
35
  composites that already exist. Drop what your queries do not use.
36
36
 
37
- ### The unique index does not stop duplicate unowned taggings
37
+ ### Duplicate unowned taggings
38
38
 
39
39
  `taggings_idx` is unique across
40
40
  `[tag_id, taggable_id, taggable_type, context, tagger_id, tagger_type]`. Because `tagger_id` and
41
- `tagger_type` are null for unowned taggings, and SQL treats nulls as distinct, **the database will
42
- accept two identical unowned taggings**. Only the Active Record uniqueness validation prevents them,
43
- and a validation cannot prevent a race between two concurrent writes.
41
+ `tagger_type` are null on every tagging nobody owns, and SQL compares nulls as distinct, that index
42
+ does not stop two identical unowned taggings. Only the model validation does, and a validation
43
+ cannot win a race between two concurrent writes.
44
44
 
45
- If duplicate taggings would be a problem for you, add a partial unique index. On PostgreSQL:
45
+ Migration 6 closes it with a partial unique index, `taggings_unowned_idx`, covering
46
+ `[tag_id, taggable_id, taggable_type, context]` where `tagger_id IS NULL`.
47
+
48
+ **MySQL has no partial indexes**, so it keeps the validation on its own and the migration is a no-op
49
+ there. If duplicate taggings would be a serious problem on MySQL, the usual workaround is a
50
+ generated column holding a sentinel for the null tagger, indexed uniquely alongside the rest.
51
+
52
+ If the migration fails with a uniqueness error, the table already contains duplicates. Remove them
53
+ first:
46
54
 
47
55
  ```ruby
48
- add_index :taggings,
49
- [:tag_id, :taggable_id, :taggable_type, :context],
50
- unique: true,
51
- where: "tagger_id IS NULL",
52
- name: "taggings_unowned_idx"
56
+ duplicates = MakeTaggable::Tagging
57
+ .where(tagger_id: nil)
58
+ .group(:tag_id, :taggable_id, :taggable_type, :context)
59
+ .having("COUNT(*) > 1")
60
+ .pluck(Arel.sql("MIN(id), COUNT(*)"))
61
+
62
+ duplicates.each do |keep_id, _count|
63
+ tagging = MakeTaggable::Tagging.find(keep_id)
64
+ MakeTaggable::Tagging
65
+ .where(tagger_id: nil, tag_id: tagging.tag_id, taggable_id: tagging.taggable_id,
66
+ taggable_type: tagging.taggable_type, context: tagging.context)
67
+ .where.not(id: keep_id)
68
+ .delete_all
69
+ end
53
70
  ```
54
71
 
55
72
  ## PostgreSQL
@@ -83,15 +83,19 @@ class BooksController < ApplicationController
83
83
  private
84
84
 
85
85
  def book_params
86
- params.expect(book: [:title, :tag_list])
86
+ params.expect(book: [:title, :tag_list]) # Rails 8
87
+ # params.require(:book).permit(:title, :tag_list) # Rails 7.2
87
88
  end
88
89
  end
89
90
  ```
90
91
 
92
+ `params.expect` arrived in Rails 8. On Rails 7.2 use `require` and `permit`, as commented above.
93
+
91
94
  To accept an array of tags from a multi-select, permit it as one:
92
95
 
93
96
  ```ruby
94
- params.expect(book: [:title, {tag_list: []}])
97
+ params.expect(book: [:title, {tag_list: []}]) # Rails 8
98
+ params.require(:book).permit(:title, tag_list: []) # Rails 7.2
95
99
  ```
96
100
 
97
101
  ## Find tagged records
data/docs/querying.md CHANGED
@@ -6,18 +6,18 @@
6
6
  pagination.
7
7
 
8
8
  ```ruby
9
- User.tagged_with("awesome").by_join_date.limit(20)
9
+ Book.tagged_with("sci-fi").order(published_at: :desc).limit(20)
10
10
  ```
11
11
 
12
12
  By default every tag given must be present:
13
13
 
14
14
  ```ruby
15
- User.tagged_with(["awesome", "cool"]) # carries awesome AND cool
16
- User.tagged_with(["awesome", "cool"], any: true) # carries awesome OR cool
17
- User.tagged_with(["awesome", "cool"], exclude: true) # carries NEITHER
15
+ Book.tagged_with(["sci-fi", "classic"]) # carries sci-fi AND classic
16
+ Book.tagged_with(["sci-fi", "classic"], any: true) # carries sci-fi OR classic
17
+ Book.tagged_with(["sci-fi", "classic"], exclude: true) # carries NEITHER
18
18
  ```
19
19
 
20
- Passing nothing matches nothing. `User.tagged_with([])` and `User.tagged_with("")` both return an
20
+ Passing nothing matches nothing. `Book.tagged_with([])` and `Book.tagged_with("")` both return an
21
21
  empty relation rather than every record — worth knowing when the tags come from user input.
22
22
 
23
23
  ### Options
@@ -27,7 +27,7 @@ empty relation rather than every record — worth knowing when the tags come fro
27
27
  | `:any` | Match records carrying at least one of the tags |
28
28
  | `:exclude` | Match records carrying none of the tags |
29
29
  | `:match_all` | Match records carrying only these tags and no others |
30
- | `:wild` | Match tags *containing* the given text, i.e. `%awesome%` |
30
+ | `:wild` | Match tags *containing* the given text, i.e. `%sci%` |
31
31
  | `:on` | Restrict to one context |
32
32
  | `:owned_by` | Restrict to tags applied by one tagger |
33
33
  | `:order_by_matching_tag_count` | With `:any`, order by how many tags matched, most first |
@@ -37,15 +37,15 @@ empty relation rather than every record — worth knowing when the tags come fro
37
37
  `:wild` combines with `:any` or `:exclude`:
38
38
 
39
39
  ```ruby
40
- User.tagged_with(["awe", "co"], any: true, wild: true)
40
+ Book.tagged_with(["sci", "clas"], any: true, wild: true)
41
41
  ```
42
42
 
43
43
  Contexts are matched one call at a time, so chain to combine them:
44
44
 
45
45
  ```ruby
46
- User
47
- .tagged_with(["awesome", "cool"], on: :tags, any: true)
48
- .tagged_with(["smart", "shy"], on: :skills, any: true)
46
+ Book
47
+ .tagged_with(["sci-fi", "fantasy"], on: :genres, any: true)
48
+ .tagged_with(["desert", "space"], on: :tags, any: true)
49
49
  ```
50
50
 
51
51
  ### Case sensitivity
@@ -105,6 +105,9 @@ noticeably cheaper on a large table.
105
105
 
106
106
  ## Related records
107
107
 
108
+ These examples switch to a `User` model tagged on `:skills` — sharing tags is easiest to picture
109
+ with people.
110
+
108
111
  Records sharing tags with this one, ordered by how many tags matched:
109
112
 
110
113
  ```ruby
@@ -6,5 +6,5 @@ module MakeTaggable
6
6
  #
7
7
  # @return [String]
8
8
  #
9
- VERSION = "1.0.0"
9
+ VERSION = "1.1.1"
10
10
  end
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.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Kennedy
@@ -46,6 +46,7 @@ files:
46
46
  - db/migrate/3_change_tag_name_collation_mysql.rb
47
47
  - db/migrate/4_add_index_to_tags.rb
48
48
  - db/migrate/5_add_index_to_taggings.rb
49
+ - db/migrate/6_add_unowned_taggings_unique_index.rb
49
50
  - docs/caching.md
50
51
  - docs/configuration.md
51
52
  - docs/contexts.md
@@ -116,6 +117,10 @@ post_install_message: |
116
117
 
117
118
  4. Requirements are now Ruby 3.2 and Active Record 7.2 or newer.
118
119
 
120
+ Note for anyone upgrading past 1.0: a later migration adds a unique index
121
+ preventing duplicate unowned taggings. If it fails, your taggings table
122
+ already holds duplicates -- docs/database.md has a snippet to clear them.
123
+
119
124
  Install any new migrations:
120
125
 
121
126
  rails make_taggable_engine:install:migrations