make_taggable 0.7.4 → 1.0.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/CONTRIBUTING.md +65 -22
  4. data/LICENSE.md +18 -17
  5. data/README.md +73 -444
  6. data/UPGRADING.md +28 -2
  7. data/docs/caching.md +93 -0
  8. data/docs/configuration.md +108 -0
  9. data/docs/contexts.md +147 -0
  10. data/docs/database.md +106 -0
  11. data/docs/getting-started.md +114 -0
  12. data/docs/migrating-from-aato.md +67 -0
  13. data/docs/ownership.md +111 -0
  14. data/docs/parsers.md +109 -0
  15. data/docs/querying.md +163 -0
  16. data/docs/tag-clouds.md +74 -0
  17. data/lib/make_taggable/default_parser.rb +45 -32
  18. data/lib/make_taggable/engine.rb +6 -0
  19. data/lib/make_taggable/generic_parser.rb +30 -4
  20. data/lib/make_taggable/tag.rb +121 -19
  21. data/lib/make_taggable/tag_list.rb +77 -24
  22. data/lib/make_taggable/taggable/cache.rb +61 -11
  23. data/lib/make_taggable/taggable/collection.rb +102 -30
  24. data/lib/make_taggable/taggable/core.rb +154 -34
  25. data/lib/make_taggable/taggable/ownership.rb +82 -4
  26. data/lib/make_taggable/taggable/related.rb +66 -3
  27. data/lib/make_taggable/taggable/tag_list_type.rb +8 -0
  28. data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +10 -0
  29. data/lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb +10 -0
  30. data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +10 -0
  31. data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +15 -0
  32. data/lib/make_taggable/taggable/tagged_with_query.rb +17 -0
  33. data/lib/make_taggable/taggable.rb +34 -33
  34. data/lib/make_taggable/tagger.rb +73 -12
  35. data/lib/make_taggable/tagging.rb +30 -3
  36. data/lib/make_taggable/tags_helper.rb +22 -1
  37. data/lib/make_taggable/utils.rb +40 -5
  38. data/lib/make_taggable/version.rb +8 -1
  39. data/lib/make_taggable.rb +153 -8
  40. data/make_taggable.gemspec +13 -20
  41. metadata +50 -165
  42. data/.dummyrc +0 -17
  43. data/.github/workflows/ci.yml +0 -140
  44. data/.github/workflows/standard-ci.yml +0 -27
  45. data/.gitignore +0 -17
  46. data/.rspec +0 -3
  47. data/Appraisals +0 -15
  48. data/Gemfile +0 -16
  49. data/Rakefile +0 -13
  50. data/gemfiles/rails_5.gemfile +0 -9
  51. data/gemfiles/rails_6.gemfile +0 -9
  52. data/gemfiles/rails_6_1.gemfile +0 -9
  53. data/gemfiles/rails_master.gemfile +0 -9
  54. data/lib/tasks/setup_test_db.rake +0 -8
data/docs/caching.md ADDED
@@ -0,0 +1,93 @@
1
+ # Caching tag lists
2
+
3
+ Displaying a record's tags normally means loading its tags. If you show tags on an index page, that
4
+ is a query per record or an `includes` on every page. Caching stores the rendered list in a column
5
+ on the taggable itself, so showing it costs nothing extra.
6
+
7
+ ## Turning it on
8
+
9
+ There is no setting. Add a column named `cached_<singular context>_list` and caching switches
10
+ itself on for that context:
11
+
12
+ ```ruby
13
+ class AddCachedTagListToBooks < ActiveRecord::Migration[8.0]
14
+ def change
15
+ add_column :books, :cached_tag_list, :string
16
+ add_column :books, :cached_genre_list, :string
17
+ end
18
+ end
19
+ ```
20
+
21
+ `:tags` gives `cached_tag_list`, `:genres` gives `cached_genre_list` — singular, the same
22
+ inflection the generated methods use.
23
+
24
+ Check whether a context is cached:
25
+
26
+ ```ruby
27
+ Book.caching_tag_list? # generated per context
28
+ Book.caching_tag_list_on?(:genres)
29
+ ```
30
+
31
+ ## Using it
32
+
33
+ Nothing changes in how you read or write tags. The column is filled on save:
34
+
35
+ ```ruby
36
+ book = Book.create!(title: "Dune", tag_list: "sci-fi, classic")
37
+ book.cached_tag_list # => "sci-fi, classic"
38
+ ```
39
+
40
+ Reading `tag_list` on a record loaded from the database parses the cached column instead of
41
+ querying the tags table:
42
+
43
+ ```ruby
44
+ book = Book.first
45
+ book.tag_list # no query against tags or taggings
46
+ ```
47
+
48
+ Show the cached string directly when you only need to display it:
49
+
50
+ ```erb
51
+ <%= book.cached_tag_list %>
52
+ ```
53
+
54
+ ## What it costs
55
+
56
+ The cache is a denormalisation, with the usual consequences.
57
+
58
+ - **It is written on save of the taggable, and only then.** Renaming or destroying a
59
+ `MakeTaggable::Tag` row directly does not touch any cached column. If you let tags be edited,
60
+ re-save the affected records, or clear the column and let it rebuild.
61
+ - **It stores the rendered string**, joined with the delimiter in force at save time. Changing
62
+ `MakeTaggable.delimiter` afterwards leaves old rows joined the old way.
63
+ - **A `string` column has a length limit.** A record with many tags can exceed it and have its
64
+ cached list truncated by the database. Use `text` if lists may be long.
65
+ - **It does not include owned tags**, because it is built from the same list `tag_list` returns.
66
+ See [ownership.md](ownership.md).
67
+
68
+ Caching is worth it for read-heavy displays of tags that rarely change. It is not a substitute for
69
+ querying — `tagged_with` always goes to the taggings table, cached column or not.
70
+
71
+ ## Rebuilding
72
+
73
+ Saving a record is **not** enough to rebuild its cached list. The column is only rewritten when the
74
+ tag list has been loaded or assigned during that request, and reading the list on a cached record
75
+ parses the column itself — so a stale value refreshes to the same stale value:
76
+
77
+ ```ruby
78
+ book = Book.find(id) # cached_tag_list is "one, two", but the tag is now named "uno"
79
+ book.save!
80
+ book.reload.cached_tag_list # => "one, two" <- unchanged
81
+ ```
82
+
83
+ Clear the column first, so reading the list falls through to the tags table:
84
+
85
+ ```ruby
86
+ Book.find_each do |book|
87
+ book.update_column(:cached_tag_list, nil)
88
+ book.tag_list # now read from tags, not from the column
89
+ book.save!
90
+ end
91
+ ```
92
+
93
+ That is the reliable way to repair cached lists after renaming or merging tags out of band.
@@ -0,0 +1,108 @@
1
+ # Configuration
2
+
3
+ Settings live on `MakeTaggable`. Set them in an initializer:
4
+
5
+ ```ruby
6
+ # config/initializers/make_taggable.rb
7
+ MakeTaggable.setup do |config|
8
+ config.force_lowercase = true
9
+ config.remove_unused_tags = true
10
+ end
11
+ ```
12
+
13
+ Or assign them directly — `MakeTaggable` forwards to the same configuration object:
14
+
15
+ ```ruby
16
+ MakeTaggable.force_lowercase = true
17
+ ```
18
+
19
+ ## Settings
20
+
21
+ | Setting | Default | Effect |
22
+ |---|---|---|
23
+ | `force_lowercase` | `false` | Downcase tag names before saving |
24
+ | `force_parameterize` | `false` | Parameterize tag names before saving |
25
+ | `strict_case_match` | `false` | Match tags case sensitively |
26
+ | `remove_unused_tags` | `false` | Destroy a tag row when its last tagging goes |
27
+ | `tags_counter` | `true` | Maintain the `taggings_count` counter cache |
28
+ | `default_parser` | `MakeTaggable::DefaultParser` | Class used to parse tag input |
29
+ | `delimiter` | `","` | Delimiter, or delimiters, separating tags |
30
+ | `tags_table` | `:tags` | Table backing `MakeTaggable::Tag` |
31
+ | `taggings_table` | `:taggings` | Table backing `MakeTaggable::Tagging` |
32
+ | `force_binary_collation` | `false` | MySQL only: exact matching including accents |
33
+
34
+ ### `force_lowercase`
35
+
36
+ Tags are downcased as they are cleaned, so `"Ruby"` is stored as `"ruby"`.
37
+
38
+ ### `force_parameterize`
39
+
40
+ Tags are parameterized, so `"Ruby on Rails"` is stored as `"ruby-on-rails"`. Applied after
41
+ `force_lowercase` if both are on.
42
+
43
+ ### `strict_case_match`
44
+
45
+ Off, tag lookups are case insensitive and `"Ruby"` finds `"ruby"`; a list containing both keeps one.
46
+ On, they are two distinct tags and lookups match exactly.
47
+
48
+ Note the interaction with the database: the shipped migrations put a **case-sensitive** unique index
49
+ on `tags.name`, so `"Ruby"` and `"ruby"` can both exist as rows even with `strict_case_match` off.
50
+ The library avoids creating both, but data loaded another way can still contain them.
51
+
52
+ ### `remove_unused_tags`
53
+
54
+ When the last tagging referencing a tag is destroyed, the tag row is destroyed too. Requires
55
+ `tags_counter`, since it reads the counter cache to decide.
56
+
57
+ ### `tags_counter`
58
+
59
+ Keeps `tags.taggings_count` up to date. Turning it off avoids a write to the tags row on every
60
+ tagging, at the cost of `Tag.most_used`, `Tag.least_used` and `remove_unused_tags`, all of which
61
+ read that counter.
62
+
63
+ Changing this on an existing application leaves the existing counts frozen at their current values
64
+ rather than resetting them.
65
+
66
+ ### `default_parser`
67
+
68
+ See [parsers.md](parsers.md).
69
+
70
+ ### `delimiter`
71
+
72
+ Deprecated in favour of a parser, and warns when Active Record has a logger. Delimiters are literal
73
+ strings — metacharacters are escaped for you. See [parsers.md](parsers.md).
74
+
75
+ ### `tags_table` and `taggings_table`
76
+
77
+ Rename the tables, for instance to keep them out of the way of another tagging library:
78
+
79
+ ```ruby
80
+ MakeTaggable.tags_table = "mt_tags"
81
+ MakeTaggable.taggings_table = "mt_taggings"
82
+ ```
83
+
84
+ These are read when `MakeTaggable::Tag` and `MakeTaggable::Tagging` are first loaded and when the
85
+ migrations run, so set them in an initializer, before anything touches the models. Changing them on
86
+ an application that already has data means renaming the tables yourself.
87
+
88
+ ### `force_binary_collation`
89
+
90
+ MySQL only. Switches the `tags.name` column to `utf8mb4_bin`, so names compare exactly including
91
+ accented and other multi-byte characters, and forces `strict_case_match` on.
92
+
93
+ Note that the shipped migrations already apply `utf8mb4_bin` to the column on MySQL. What this
94
+ setting adds is `strict_case_match`, which is what actually makes the library's lookups case
95
+ sensitive — see [database.md](database.md).
96
+
97
+ ```ruby
98
+ MakeTaggable.force_binary_collation = true
99
+ ```
100
+
101
+ Or as a one-off migration:
102
+
103
+ ```shell
104
+ rails make_taggable_engine:tag_names:collate_bin
105
+ ```
106
+
107
+ Setting `strict_case_match = false` afterwards has no effect while binary collation is on. See
108
+ [database.md](database.md).
data/docs/contexts.md ADDED
@@ -0,0 +1,147 @@
1
+ # Tag contexts
2
+
3
+ A context is a named group of tags on a model. One model can tag in as many contexts as you like,
4
+ and the tags in each are kept apart: adding `"ruby"` to `skill_list` says nothing about
5
+ `interest_list`.
6
+
7
+ ```ruby
8
+ class User < ApplicationRecord
9
+ make_taggable # the :tags context
10
+ make_taggable :skills, :interests
11
+ end
12
+ ```
13
+
14
+ ## What each context generates
15
+
16
+ Given `make_taggable :skills`, these appear on the model. Singular and plural forms are worked out
17
+ with Active Support's inflector, so `:skills` gives `skill_list` and `skills`.
18
+
19
+ | Method | Kind | What it gives you |
20
+ |---|---|---|
21
+ | `skill_list` | instance | The tag names, as a `MakeTaggable::TagList` |
22
+ | `skill_list=` | instance | Replaces the whole list |
23
+ | `all_skills_list` | instance | The names including tags applied by an owner |
24
+ | `skills` | instance | The `MakeTaggable::Tag` records, through the association |
25
+ | `skill_taggings` | instance | The `MakeTaggable::Tagging` join records |
26
+ | `skill_counts` | class and instance | Tags used, each carrying a `count` |
27
+ | `top_skills(limit = 10)` | class and instance | The most used tags, most first |
28
+ | `skills_from(owner)` | instance | Only the tags that owner applied |
29
+ | `find_related_skills` | instance | Other records sharing these tags |
30
+ | `find_related_skills_for(klass)` | instance | The same, against another model |
31
+ | `caching_skill_list?` | class | Whether this context is cached in a column |
32
+
33
+ `skill_list` takes part in dirty tracking like any other attribute:
34
+
35
+ ```ruby
36
+ user.skill_list = "diving"
37
+ user.skill_list_changed? # => true
38
+ user.skill_list_was # => ["jogging"]
39
+ user.skill_list_change # => [["jogging"], ["diving"]]
40
+ user.will_save_change_to_skill_list?
41
+ ```
42
+
43
+ ## Adding contexts later
44
+
45
+ Calling `make_taggable` again adds contexts rather than replacing them, which is what lets a
46
+ subclass extend its parent:
47
+
48
+ ```ruby
49
+ class Manual < Book
50
+ make_taggable :audiences
51
+ end
52
+
53
+ Manual.tag_types # => [:tags, :audiences]
54
+ ```
55
+
56
+ ## Preserving tag order
57
+
58
+ By default tags come back in whatever order the database returns. To keep them in the order they
59
+ were added, declare the model with `make_ordered_taggable`:
60
+
61
+ ```ruby
62
+ class Route < ApplicationRecord
63
+ make_ordered_taggable # the :tags context, ordered
64
+ make_ordered_taggable :stops
65
+ end
66
+
67
+ route.tag_list = "east, south"
68
+ route.save
69
+ route.tag_list = "north, east, south, west"
70
+ route.save
71
+
72
+ route.reload.tag_list # => ["north", "east", "south", "west"]
73
+ ```
74
+
75
+ Ordering is a property of the model, not of a single context: the last call wins for every context
76
+ on that model. It also changes what counts as a change — reordering the same tags marks the list
77
+ dirty on an ordered model, and does not on an unordered one.
78
+
79
+ ## Contexts created at runtime
80
+
81
+ You do not have to declare a context up front. Anything you write through `set_tag_list_on` is
82
+ saved and read back, which is how user-defined tag groups are built:
83
+
84
+ ```ruby
85
+ user = User.new(name: "Bobby")
86
+
87
+ user.set_tag_list_on(:customs, "same, as, tag, list")
88
+ user.tag_list_on(:customs) # => ["same", "as", "tag", "list"]
89
+ user.save
90
+
91
+ user.tags_on(:customs) # => [#<MakeTaggable::Tag name: "same">, ...]
92
+ user.tag_counts_on(:customs)
93
+
94
+ User.tagged_with("same", on: :customs) # => [user]
95
+ ```
96
+
97
+ `tagging_contexts` lists everything the record tags in, declared and dynamic together:
98
+
99
+ ```ruby
100
+ user.tagging_contexts # => ["tags", "skills", "interests", "customs"]
101
+ ```
102
+
103
+ Dynamic contexts get none of the generated methods in the table above — there is no
104
+ `custom_list` — so reach them through `tag_list_on`, `set_tag_list_on` and `tags_on`.
105
+
106
+ ## A separate vocabulary for one context
107
+
108
+ Tags are shared across contexts and models by default: one `tags` row named `"ruby"` serves
109
+ everything. To keep a context's tags separate, subclass `MakeTaggable::Tag` and override the hook
110
+ that resolves names to records:
111
+
112
+ ```ruby
113
+ class Market < MakeTaggable::Tag
114
+ end
115
+
116
+ class Company < ApplicationRecord
117
+ make_taggable :markets, :locations
118
+
119
+ private
120
+
121
+ def find_or_create_tags_from_list_with_context(tag_list, context)
122
+ if context.to_sym == :markets
123
+ Market.find_or_create_all_with_like_by_name(tag_list)
124
+ else
125
+ super
126
+ end
127
+ end
128
+ end
129
+ ```
130
+
131
+ This only genuinely separates the vocabularies if the tags table has a `type` column. Without one,
132
+ Active Record has nowhere to record the subclass: rows created through `Market` are saved as plain
133
+ tags, `Market.count` returns every tag in the table, and reloading a record gives you a
134
+ `MakeTaggable::Tag` back. Add the column to get real separation:
135
+
136
+ ```ruby
137
+ class AddTypeToTags < ActiveRecord::Migration[8.0]
138
+ def change
139
+ add_column MakeTaggable.tags_table, :type, :string
140
+ add_index MakeTaggable.tags_table, :type
141
+ end
142
+ end
143
+ ```
144
+
145
+ Note that the shipped migrations put a unique index on `tags.name`, so two tags cannot share a name
146
+ even across subclasses. If a market and a genre both need to be called "Energy", widen that index to
147
+ cover `[:name, :type]`.
data/docs/database.md ADDED
@@ -0,0 +1,106 @@
1
+ # Database
2
+
3
+ ## Schema
4
+
5
+ Two tables, installed by `rails make_taggable_engine:install:migrations`.
6
+
7
+ **`tags`** — one row per distinct tag name.
8
+
9
+ | Column | Type | Notes |
10
+ |---|---|---|
11
+ | `name` | string | Limited to 255 characters by a validation; uniquely indexed |
12
+ | `taggings_count` | integer | Counter cache, maintained unless `tags_counter` is off |
13
+ | `created_at`, `updated_at` | datetime | |
14
+
15
+ **`taggings`** — joins a tag to the record it was applied to.
16
+
17
+ | Column | Type | Notes |
18
+ |---|---|---|
19
+ | `tag_id` | reference | Foreign key to `tags` |
20
+ | `taggable_type`, `taggable_id` | polymorphic reference | The record being tagged |
21
+ | `tagger_type`, `tagger_id` | polymorphic reference | Who applied it; null when unowned |
22
+ | `context` | string(128) | The tag context, e.g. `"skills"` |
23
+ | `created_at`, `updated_at` | datetime | |
24
+
25
+ Both table names are configurable — see [configuration.md](configuration.md).
26
+
27
+ ## Indexes
28
+
29
+ The shipped migrations index `taggings` heavily: the polymorphic references index themselves, and
30
+ migration 5 adds standalone indexes on `taggable_id`, `tagger_id`, `taggable_type` and `context`,
31
+ plus four composites.
32
+
33
+ That suits read-heavy tagging. If your application writes taggings in bulk, the write cost is worth
34
+ looking at — every index is maintained on insert, and several of the standalone ones are prefixes of
35
+ composites that already exist. Drop what your queries do not use.
36
+
37
+ ### The unique index does not stop duplicate unowned taggings
38
+
39
+ `taggings_idx` is unique across
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.
44
+
45
+ If duplicate taggings would be a problem for you, add a partial unique index. On PostgreSQL:
46
+
47
+ ```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"
53
+ ```
54
+
55
+ ## PostgreSQL
56
+
57
+ - `named_like` uses `ILIKE`, so partial matching is case insensitive regardless of collation.
58
+ - Tag counts group by every tag column, as PostgreSQL requires.
59
+ - Nothing extra is needed for non-ASCII tags.
60
+
61
+ ## MySQL
62
+
63
+ **The shipped migrations collate tag names as `utf8mb4_bin`.** Migration 3 applies it
64
+ unconditionally on MySQL, so the `tags.name` column is case- and accent-sensitive at the database
65
+ level whatever `force_binary_collation` is set to.
66
+
67
+ That matters if you query the column yourself: `WHERE name LIKE '%ruby%'` will not match `"Ruby"` on
68
+ MySQL, though it does on SQLite and PostgreSQL. The library's own lookups are unaffected, because
69
+ `Tag.named` and `Tag.named_any` compare with `LOWER()` on both sides rather than relying on the
70
+ collation.
71
+
72
+ To go back to a case-insensitive column, run `rails make_taggable_engine:tag_names:collate_ci`.
73
+
74
+ Other notes:
75
+
76
+ - For exact matching including accented characters *and* case-sensitive library lookups, turn on
77
+ binary collation through the configuration, which also flips `strict_case_match`:
78
+
79
+ ```ruby
80
+ MakeTaggable.force_binary_collation = true
81
+ ```
82
+
83
+ or run `rails make_taggable_engine:tag_names:collate_bin`. To go back,
84
+ `rails make_taggable_engine:tag_names:collate_ci`.
85
+
86
+ Binary collation forces `strict_case_match` on, and `strict_case_match = false` has no effect
87
+ while it is on.
88
+
89
+ - Tag count queries fetch matching ids first rather than using a subquery, which MySQL optimises
90
+ poorly.
91
+
92
+ ## SQLite
93
+
94
+ Fine for development and for the test suite, with one real limitation:
95
+
96
+ **SQLite cannot change the case of non-ASCII characters.** Its built-in `LOWER()` and `UPPER()`
97
+ handle ASCII only, so case-insensitive matching of, say, Cyrillic or Greek tags does not work. `"ПРИВЕТ"`
98
+ and `"привет"` are two different tags as far as SQLite is concerned.
99
+
100
+ If that matters, load the [ICU extension](https://www.sqlite.org/src/artifact?ci=trunk&filename=ext/icu/README.txt),
101
+ or set `MakeTaggable.strict_case_match = true` so the behaviour is at least consistent.
102
+
103
+ ## Tag length
104
+
105
+ `name` is validated at 255 characters, and the column is a `string`. Tags longer than that fail
106
+ validation rather than being truncated.
@@ -0,0 +1,114 @@
1
+ # Getting started
2
+
3
+ ## Requirements
4
+
5
+ MakeTaggable needs Ruby 3.2 or newer and Active Record 7.2 or newer. It is tested against Rails
6
+ 7.2, 8.0 and 8.1 on SQLite, MySQL and PostgreSQL.
7
+
8
+ ## Install
9
+
10
+ Add the gem:
11
+
12
+ ```shell
13
+ bundle add make_taggable
14
+ ```
15
+
16
+ Copy the migrations into your application and run them:
17
+
18
+ ```shell
19
+ rails make_taggable_engine:install:migrations
20
+ rails db:migrate
21
+ ```
22
+
23
+ That creates two tables: `tags`, holding each distinct tag name, and `taggings`, joining a tag to
24
+ the record it was applied to. Both table names are configurable — see
25
+ [configuration.md](configuration.md).
26
+
27
+ ## Make a model taggable
28
+
29
+ ```ruby
30
+ class Book < ApplicationRecord
31
+ make_taggable
32
+ end
33
+ ```
34
+
35
+ Called with no arguments, `make_taggable` tags on the `:tags` context, which is the default the
36
+ rest of the library assumes. Name your own contexts instead, or as well:
37
+
38
+ ```ruby
39
+ class Book < ApplicationRecord
40
+ make_taggable :genres, :moods
41
+ end
42
+ ```
43
+
44
+ Each context generates its own set of methods. `:genres` gives you `genre_list`, `genres`,
45
+ `genre_counts`, `top_genres` and more — see [contexts.md](contexts.md) for the full list.
46
+
47
+ ## Read and write tags
48
+
49
+ A tag list behaves like an array of strings:
50
+
51
+ ```ruby
52
+ book = Book.new(title: "Dune")
53
+
54
+ book.tag_list = "sci-fi, classic"
55
+ book.save
56
+
57
+ book.tag_list # => ["sci-fi", "classic"]
58
+ book.tags # => [#<MakeTaggable::Tag name: "sci-fi">, #<MakeTaggable::Tag name: "classic">]
59
+ ```
60
+
61
+ Add and remove individual tags. Nothing is written until you save:
62
+
63
+ ```ruby
64
+ book.tag_list.add("desert")
65
+ book.tag_list.remove("classic")
66
+ book.save
67
+ ```
68
+
69
+ To pass a delimited string rather than separate arguments, ask for it to be parsed:
70
+
71
+ ```ruby
72
+ book.tag_list.add("desert, epic", parse: true)
73
+ ```
74
+
75
+ Assigning replaces the whole list, so `tag_list =` removes anything not in the new value.
76
+
77
+ ## Permit the parameter
78
+
79
+ `tag_list` is an ordinary attribute as far as your controller is concerned:
80
+
81
+ ```ruby
82
+ class BooksController < ApplicationController
83
+ private
84
+
85
+ def book_params
86
+ params.expect(book: [:title, :tag_list])
87
+ end
88
+ end
89
+ ```
90
+
91
+ To accept an array of tags from a multi-select, permit it as one:
92
+
93
+ ```ruby
94
+ params.expect(book: [:title, {tag_list: []}])
95
+ ```
96
+
97
+ ## Find tagged records
98
+
99
+ ```ruby
100
+ Book.tagged_with("sci-fi") # carries this tag
101
+ Book.tagged_with(["sci-fi", "classic"]) # carries both
102
+ Book.tagged_with(["sci-fi", "classic"], any: true) # carries either
103
+ ```
104
+
105
+ `tagged_with` returns a relation, so it chains with your own scopes and with pagination.
106
+ [querying.md](querying.md) covers every option.
107
+
108
+ ## Where to go next
109
+
110
+ - [contexts.md](contexts.md) — multiple contexts, ordered tags, contexts created at runtime
111
+ - [querying.md](querying.md) — every `tagged_with` option, and the counting API
112
+ - [ownership.md](ownership.md) — tags belonging to a user, and why `tag_list` can look empty
113
+ - [configuration.md](configuration.md) — every setting
114
+ - [database.md](database.md) — schema, indexes, and per-adapter notes
@@ -0,0 +1,67 @@
1
+ # Migrating from acts-as-taggable-on
2
+
3
+ MakeTaggable began as a fork of
4
+ [acts-as-taggable-on](https://github.com/mbleigh/acts-as-taggable-on). The behaviour is largely the
5
+ same; the names are not.
6
+
7
+ ## Method names
8
+
9
+ | acts-as-taggable-on | MakeTaggable |
10
+ |---|---|
11
+ | `acts_as_taggable` | `make_taggable` |
12
+ | `acts_as_taggable_on :skills` | `make_taggable :skills` |
13
+ | `acts_as_ordered_taggable` | `make_ordered_taggable` |
14
+ | `acts_as_ordered_taggable_on :skills` | `make_ordered_taggable :skills` |
15
+ | `acts_as_tagger` | `make_tagger` |
16
+
17
+ > Versions of MakeTaggable before 1.0 kept the `acts_as_*` names as aliases. They were removed in
18
+ > 1.0 — see [UPGRADING.md](../UPGRADING.md).
19
+
20
+ Everything generated per context is unchanged: `skill_list`, `skills`, `skill_counts`,
21
+ `top_skills`, `skills_from`, `find_related_skills` and the rest all keep their names.
22
+
23
+ ## Constants
24
+
25
+ | acts-as-taggable-on | MakeTaggable |
26
+ |---|---|
27
+ | `ActsAsTaggableOn::Tag` | `MakeTaggable::Tag` |
28
+ | `ActsAsTaggableOn::Tagging` | `MakeTaggable::Tagging` |
29
+ | `ActsAsTaggableOn::TagList` | `MakeTaggable::TagList` |
30
+ | `ActsAsTaggableOn::GenericParser` | `MakeTaggable::GenericParser` |
31
+ | `ActsAsTaggableOn::DefaultParser` | `MakeTaggable::DefaultParser` |
32
+ | `ActsAsTaggableOn.setup` | `MakeTaggable.setup` |
33
+
34
+ Configuration keys are the same. `ActsAsTaggableOn.force_lowercase` becomes
35
+ `MakeTaggable.force_lowercase`, and so on — see [configuration.md](configuration.md).
36
+
37
+ ## The database
38
+
39
+ The schema is compatible: the same `tags` and `taggings` tables, with the same columns. If you are
40
+ switching an application over, you do not need to move any data.
41
+
42
+ Two differences to check before you do:
43
+
44
+ - **`taggable_type` and `tagger_type` hold class names.** They do not mention either library, so
45
+ they carry across untouched.
46
+ - **acts-as-taggable-on's migrations differ in their indexes** depending on which version installed
47
+ them. MakeTaggable's migrations are separate files with their own version numbers, so running
48
+ `rails make_taggable_engine:install:migrations` on a database that already has the tables will try
49
+ to create them again. Skip those migrations, or mark them as run, rather than letting them
50
+ execute.
51
+
52
+ ## Behaviour differences
53
+
54
+ - **Delimiters are literal strings.** acts-as-taggable-on interpolated the configured delimiter into
55
+ a pattern unescaped, so splitting on `|` meant passing `'\|'`. MakeTaggable escapes for you: pass
56
+ `"|"`. See [parsers.md](parsers.md).
57
+ - **`make_taggable` with no arguments tags on `:tags`.** In acts-as-taggable-on, `acts_as_taggable`
58
+ was the no-argument form and `acts_as_taggable_on` took contexts; MakeTaggable uses one method for
59
+ both.
60
+ - **Requirements are higher.** Ruby 3.2 and Active Record 7.2, against acts-as-taggable-on's wider
61
+ range.
62
+
63
+ ## What has not changed
64
+
65
+ The parts people rely on most behave identically: `tagged_with` and all its options, tag ownership
66
+ and the rule that `tag_list` excludes owned tags, dirty tracking on `*_list` attributes, cached tag
67
+ list columns, the tag cloud helper, and custom parsers.