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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/CONTRIBUTING.md +65 -22
- data/LICENSE.md +18 -17
- data/README.md +73 -444
- data/UPGRADING.md +28 -2
- data/docs/caching.md +93 -0
- data/docs/configuration.md +108 -0
- data/docs/contexts.md +147 -0
- data/docs/database.md +106 -0
- data/docs/getting-started.md +114 -0
- data/docs/migrating-from-aato.md +67 -0
- data/docs/ownership.md +111 -0
- data/docs/parsers.md +109 -0
- data/docs/querying.md +163 -0
- data/docs/tag-clouds.md +74 -0
- data/lib/make_taggable/default_parser.rb +45 -32
- data/lib/make_taggable/engine.rb +6 -0
- data/lib/make_taggable/generic_parser.rb +30 -4
- data/lib/make_taggable/tag.rb +121 -19
- data/lib/make_taggable/tag_list.rb +77 -24
- data/lib/make_taggable/taggable/cache.rb +61 -11
- data/lib/make_taggable/taggable/collection.rb +102 -30
- data/lib/make_taggable/taggable/core.rb +154 -34
- data/lib/make_taggable/taggable/ownership.rb +82 -4
- data/lib/make_taggable/taggable/related.rb +66 -3
- data/lib/make_taggable/taggable/tag_list_type.rb +8 -0
- data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +10 -0
- data/lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb +10 -0
- data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +10 -0
- data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +15 -0
- data/lib/make_taggable/taggable/tagged_with_query.rb +17 -0
- data/lib/make_taggable/taggable.rb +34 -33
- data/lib/make_taggable/tagger.rb +73 -12
- data/lib/make_taggable/tagging.rb +30 -3
- data/lib/make_taggable/tags_helper.rb +22 -1
- data/lib/make_taggable/utils.rb +40 -5
- data/lib/make_taggable/version.rb +8 -1
- data/lib/make_taggable.rb +153 -8
- data/make_taggable.gemspec +13 -20
- metadata +50 -165
- data/.dummyrc +0 -17
- data/.github/workflows/ci.yml +0 -140
- data/.github/workflows/standard-ci.yml +0 -27
- data/.gitignore +0 -17
- data/.rspec +0 -3
- data/Appraisals +0 -15
- data/Gemfile +0 -16
- data/Rakefile +0 -13
- data/gemfiles/rails_5.gemfile +0 -9
- data/gemfiles/rails_6.gemfile +0 -9
- data/gemfiles/rails_6_1.gemfile +0 -9
- data/gemfiles/rails_master.gemfile +0 -9
- 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.
|