concerns_on_rails 1.11.1 → 1.11.2
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 +14 -0
- data/README.md +72 -4
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/sluggable.rb +22 -9
- data/lib/concerns_on_rails/models/soft_deletable.rb +45 -22
- data/lib/concerns_on_rails/models/sortable.rb +12 -3
- data/lib/concerns_on_rails/models/taggable.rb +160 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9eb1080168652222770c2ea933745175091c64599ea39e309220a9be8b241b1c
|
|
4
|
+
data.tar.gz: f39962ffb8a0e359d2137f1df9440be0a58a17d8c05d78181ba17d2ae1d0433b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee0491e22fe01259415dbc266d19b9a2e966c53e9f0a0b3acded0aa5dd60e2e7085faeeff0b7995e6d6e1849612f1611a551bd2dbb5837f2757cb0de25ebfb72
|
|
7
|
+
data.tar.gz: 6297b08086c0b8417eff076393d725e40d6e469780fb8d60e5525a1b3a3a53d57fa44a6e90457f4625a7ed7012494cb80d385e5138c8b6e8b7d4836e0ed79cdd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.11.2 (2026-06-06)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Models::Taggable**: Lightweight, dependency-free tagging over a single string column (no join tables, no tagging engine; works on any database including SQLite). `taggable_by :tags` adds `tag_list` get/set (accepts a String or an Array, stripped + de-duped), `add_tags` / `remove_tags`, a `tagged_with?` predicate, a boundary-safe `tagged_with(*tags, any:)` class scope (AND by default, OR with `any: true`), and `all_tags`. Options: `delimiter:` and `downcase:`. Reach for `acts-as-taggable-on` when you need tag contexts, ownership, or tag clouds.
|
|
7
|
+
- **Models::SoftDeletable**: `soft_deletable_by` gained `default_scope:` (default `true`) to opt out of the deleted-hiding `default_scope`; new explicit `soft_delete_all` class method (preferred over the `destroy_all` override).
|
|
8
|
+
- **Models::Sluggable**: `sluggable_by` gained `reserved_words:` (reject slugs like `new` / `edit` / `admin` — saving such a record fails validation) and `finders: true` (`Model.find` accepts a slug directly), layering friendly_id's `:reserved` / `:finders` modules.
|
|
9
|
+
- **Models::Sortable**: `sortable_by` now threads acts_as_list's `scope:` (independent position sequence per group) and `add_new_at:` (`:top` / `:bottom`) options through to `acts_as_list`.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **Models::SoftDeletable**: `soft_delete!` / `restore!` (and the bulk `soft_delete_all` / `restore_all` / `destroy_all`) now run inside a transaction, so a raising `before_*` / `after_*` hook rolls the timestamp change back instead of leaving a half-applied state — adopting `discard`'s transactional playbook.
|
|
13
|
+
|
|
14
|
+
### Notes
|
|
15
|
+
- All changes are backward-compatible: the soft-delete `default_scope` stays on by default and `destroy_all` continues to soft-delete. New models are encouraged to set `default_scope: false` and use the explicit `soft_delete_all`.
|
|
16
|
+
|
|
3
17
|
## 1.11.1 (2026-06-05)
|
|
4
18
|
|
|
5
19
|
### Added
|
data/README.md
CHANGED
|
@@ -39,6 +39,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
39
39
|
- [Sequenceable](#-sequenceable) — ordered, human-friendly reference numbers
|
|
40
40
|
- [Stateable](#-stateable) — lightweight string-backed state machine
|
|
41
41
|
- [Addressable](#-addressable) — postal address normalization + format validation
|
|
42
|
+
- [Taggable](#-taggable) — lightweight tagging over a single column
|
|
42
43
|
- **Controller concerns**
|
|
43
44
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
44
45
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -55,7 +56,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
55
56
|
|
|
56
57
|
## ✨ Why this gem?
|
|
57
58
|
|
|
58
|
-
- **
|
|
59
|
+
- **Fifteen model concerns + six controller concerns**, all production-ready
|
|
59
60
|
- **One include, one macro** — no boilerplate, no glue code
|
|
60
61
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
61
62
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -158,6 +159,13 @@ Post.friendly.find("old-slug") # still resolves to the renamed post
|
|
|
158
159
|
|
|
159
160
|
# Unique slug only within a scope column (same slug allowed in different accounts)
|
|
160
161
|
sluggable_by :title, scope: :account_id
|
|
162
|
+
|
|
163
|
+
# Reject reserved slugs — saving a record whose slug would be reserved fails validation
|
|
164
|
+
sluggable_by :title, reserved_words: %w[new edit admin]
|
|
165
|
+
|
|
166
|
+
# Let Model.find accept a slug directly (not just the id)
|
|
167
|
+
sluggable_by :title, finders: true
|
|
168
|
+
Post.find("hello-world") # resolves by slug
|
|
161
169
|
```
|
|
162
170
|
|
|
163
171
|
**Notes**
|
|
@@ -191,6 +199,8 @@ Task.last.move_higher
|
|
|
191
199
|
sortable_by :priority # ascending priority
|
|
192
200
|
sortable_by priority: :desc # descending priority
|
|
193
201
|
sortable_by :position, use_acts_as_list: false # just the default_scope ordering, no acts_as_list
|
|
202
|
+
sortable_by :position, scope: :list_id # independent position sequence per list (acts_as_list scope:)
|
|
203
|
+
sortable_by :position, add_new_at: :top # new rows insert at the top (acts_as_list add_new_at:)
|
|
194
204
|
```
|
|
195
205
|
|
|
196
206
|
**Notes**
|
|
@@ -246,7 +256,7 @@ publishable_by :published_at, default_scope: true
|
|
|
246
256
|
|
|
247
257
|
## ❌ SoftDeletable
|
|
248
258
|
|
|
249
|
-
Soft delete records using a timestamp field (default: `deleted_at`).
|
|
259
|
+
Soft delete records using a timestamp field (default: `deleted_at`). By default a `default_scope` hides deleted records — **opt out** with `soft_deletable_by :deleted_at, default_scope: false` and chain `.without_deleted` explicitly (the safer choice for new models, avoiding `default_scope`'s join/uniqueness footguns). `soft_delete!` / `restore!` and the bulk helpers run inside a transaction, so a raising hook rolls the change back.
|
|
250
260
|
|
|
251
261
|
```ruby
|
|
252
262
|
class User < ApplicationRecord
|
|
@@ -280,11 +290,14 @@ User.unscoped # everything (deleted + non-deleted)
|
|
|
280
290
|
**Bulk operations**
|
|
281
291
|
|
|
282
292
|
```ruby
|
|
283
|
-
User.
|
|
293
|
+
User.soft_delete_all # soft-deletes all matching records (explicit — preferred)
|
|
294
|
+
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility)
|
|
284
295
|
User.really_destroy_all # hard-deletes all matching records
|
|
285
296
|
User.restore_all # restores all soft-deleted records
|
|
286
297
|
```
|
|
287
298
|
|
|
299
|
+
All of these run in a transaction, so a raising hook rolls the whole batch back.
|
|
300
|
+
|
|
288
301
|
**Lifecycle hooks** — override these methods on the model:
|
|
289
302
|
|
|
290
303
|
```ruby
|
|
@@ -775,6 +788,45 @@ end
|
|
|
775
788
|
|
|
776
789
|
---
|
|
777
790
|
|
|
791
|
+
## 🏷️ Taggable
|
|
792
|
+
|
|
793
|
+
Lightweight, dependency-free tagging stored in a **single string column** — no join tables, no tagging engine. Works on any database, including SQLite.
|
|
794
|
+
|
|
795
|
+
```ruby
|
|
796
|
+
class Article < ApplicationRecord
|
|
797
|
+
include ConcernsOnRails::Taggable
|
|
798
|
+
|
|
799
|
+
taggable_by :tags # default column :tags
|
|
800
|
+
# taggable_by :skills, downcase: true # custom column, case-folded
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
article = Article.new
|
|
804
|
+
article.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
|
|
805
|
+
article.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
|
|
806
|
+
article.add_tags("api")
|
|
807
|
+
article.remove_tags("Rails")
|
|
808
|
+
article.tagged_with?("ruby") # => membership predicate
|
|
809
|
+
article.save!
|
|
810
|
+
|
|
811
|
+
Article.tagged_with("ruby", "rails") # records carrying BOTH tags
|
|
812
|
+
Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
|
|
813
|
+
Article.all_tags # => sorted unique tags in use
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
**Options**
|
|
817
|
+
|
|
818
|
+
| Option | Default | Purpose |
|
|
819
|
+
|--------------|---------|------------------------------------------------------------------|
|
|
820
|
+
| `delimiter:` | `","` | Character joining the stored tags (a tag must not contain it). |
|
|
821
|
+
| `downcase:` | `false` | Case-fold tags on write so matching is case-insensitive. |
|
|
822
|
+
|
|
823
|
+
**Notes**
|
|
824
|
+
- Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
|
|
825
|
+
- Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
|
|
826
|
+
- Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, counts/clouds, or polymorphic tags shared across models.
|
|
827
|
+
|
|
828
|
+
---
|
|
829
|
+
|
|
778
830
|
# 🎮 Controller Concerns
|
|
779
831
|
|
|
780
832
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1023,13 +1075,29 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1023
1075
|
|
|
1024
1076
|
---
|
|
1025
1077
|
|
|
1078
|
+
## 🧭 Philosophy & when to reach for a dedicated gem
|
|
1079
|
+
|
|
1080
|
+
`concerns_on_rails` aims to cover the common 80% of each behavior with **one `include` + one declarative macro**, **schema-validated** configuration, **no-surprise defaults**, and **lean dependencies** (only `acts_as_list` and `friendly_id`; controller concerns have none). It is deliberately *not* a re-implementation of the category leaders — reach for a dedicated gem when you outgrow the basics:
|
|
1081
|
+
|
|
1082
|
+
| Need | Use instead |
|
|
1083
|
+
|------|-------------|
|
|
1084
|
+
| Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
|
|
1085
|
+
| Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
|
|
1086
|
+
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
1087
|
+
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
1088
|
+
| Audit trails / version history | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
|
|
1089
|
+
|
|
1090
|
+
`Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
|
|
1091
|
+
|
|
1092
|
+
---
|
|
1093
|
+
|
|
1026
1094
|
## 🛠️ Development
|
|
1027
1095
|
|
|
1028
1096
|
```sh
|
|
1029
1097
|
bundle install # install dev dependencies
|
|
1030
1098
|
bundle exec rspec # run the test suite
|
|
1031
1099
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1032
|
-
gem install ./concerns_on_rails-1.11.
|
|
1100
|
+
gem install ./concerns_on_rails-1.11.2.gem # install locally
|
|
1033
1101
|
```
|
|
1034
1102
|
|
|
1035
1103
|
The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
|
|
@@ -34,27 +34,40 @@ module ConcernsOnRails
|
|
|
34
34
|
# Define sluggable field, with optional friendly_id features.
|
|
35
35
|
# Example:
|
|
36
36
|
# sluggable_by :wonderful_name
|
|
37
|
-
# sluggable_by :title, history: true
|
|
38
|
-
# sluggable_by :title, scope: :account_id
|
|
39
|
-
|
|
37
|
+
# sluggable_by :title, history: true # old slugs keep resolving (needs a friendly_id_slugs table)
|
|
38
|
+
# sluggable_by :title, scope: :account_id # slugs unique per scope column
|
|
39
|
+
# sluggable_by :title, reserved_words: %w[new] # block these slugs (a UUID is appended instead)
|
|
40
|
+
# sluggable_by :title, finders: true # Model.find accepts a slug directly
|
|
41
|
+
def sluggable_by(field, history: false, scope: nil, reserved_words: nil, finders: false)
|
|
40
42
|
self.sluggable_field = field.to_sym
|
|
41
43
|
ensure_columns!("ConcernsOnRails::Models::Sluggable", [sluggable_field, scope].compact)
|
|
42
|
-
|
|
44
|
+
return unless history || scope || reserved_words || finders
|
|
45
|
+
|
|
46
|
+
reconfigure_friendly_id(history: history, scope: scope,
|
|
47
|
+
reserved_words: reserved_words, finders: finders)
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
private
|
|
46
51
|
|
|
47
52
|
# Re-runs friendly_id with the extra modules. friendly_id merges config
|
|
48
|
-
# across calls, so this layers :history / :scoped
|
|
49
|
-
def reconfigure_friendly_id(history:, scope:)
|
|
53
|
+
# across calls, so this layers :history / :scoped / :finders / :reserved onto :slugged.
|
|
54
|
+
def reconfigure_friendly_id(history:, scope:, reserved_words: nil, finders: false)
|
|
50
55
|
modules = [:slugged]
|
|
51
56
|
modules << :history if history
|
|
52
57
|
modules << :scoped if scope
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
modules << :finders if finders
|
|
59
|
+
modules << :reserved if reserved_words
|
|
55
60
|
# friendly_id's second argument is a positional options hash (not kwargs),
|
|
56
61
|
# so pass it positionally to stay correct on both Ruby 2.7 and 3.x.
|
|
57
|
-
friendly_id(:slug_source,
|
|
62
|
+
friendly_id(:slug_source, friendly_id_options(modules, scope, reserved_words))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build friendly_id's positional options hash from the resolved modules.
|
|
66
|
+
def friendly_id_options(modules, scope, reserved_words)
|
|
67
|
+
options = { use: modules }
|
|
68
|
+
options[:scope] = scope if scope
|
|
69
|
+
options[:reserved_words] = Array(reserved_words).map(&:to_s) if reserved_words
|
|
70
|
+
options
|
|
58
71
|
end
|
|
59
72
|
end
|
|
60
73
|
|
|
@@ -9,6 +9,11 @@ module ConcernsOnRails
|
|
|
9
9
|
# declare class attributes and set default values
|
|
10
10
|
class_attribute :soft_delete_field, instance_accessor: false, default: :deleted_at
|
|
11
11
|
class_attribute :soft_delete_touch, instance_accessor: false, default: true
|
|
12
|
+
# Whether `.all` hides soft-deleted rows via a default_scope. ON by default for
|
|
13
|
+
# backwards compatibility; opt out with `soft_deletable_by ..., default_scope: false`.
|
|
14
|
+
# A default_scope is sticky and breaks unscoped joins / uniqueness validations /
|
|
15
|
+
# eager-loading, so new models are encouraged to disable it and chain `.without_deleted`.
|
|
16
|
+
class_attribute :soft_delete_default_scope, instance_accessor: false, default: true
|
|
12
17
|
|
|
13
18
|
# scopes
|
|
14
19
|
scope :active, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
|
|
@@ -19,25 +24,35 @@ module ConcernsOnRails
|
|
|
19
24
|
scope :with_deleted, -> { unscope(where: soft_delete_field) }
|
|
20
25
|
# Records soft-deleted within the last `duration` (e.g. `deleted_within(7.days)`).
|
|
21
26
|
scope :deleted_within, ->(duration) { soft_deleted.where(soft_delete_field => duration.ago..) }
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
|
|
28
|
+
# Hide soft-deleted rows from `.all` only when enabled (the default). The block is
|
|
29
|
+
# evaluated lazily, so toggling `soft_delete_default_scope` via the macro takes effect.
|
|
30
|
+
default_scope { soft_delete_default_scope ? without_deleted : all }
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
class_methods do
|
|
27
34
|
include ConcernsOnRails::Support::ColumnGuard
|
|
28
35
|
|
|
29
|
-
# Define soft delete field and options
|
|
36
|
+
# Define soft delete field and options.
|
|
30
37
|
# Example:
|
|
31
38
|
# soft_deletable_by :deleted_at, touch: false
|
|
32
|
-
|
|
39
|
+
# soft_deletable_by :deleted_at, default_scope: false # don't hide deleted rows from .all
|
|
40
|
+
def soft_deletable_by(field = nil, touch: true, default_scope: true)
|
|
33
41
|
self.soft_delete_field = field || :deleted_at
|
|
34
42
|
self.soft_delete_touch = touch
|
|
43
|
+
self.soft_delete_default_scope = default_scope
|
|
35
44
|
ensure_columns!("ConcernsOnRails::Models::SoftDeletable", soft_delete_field)
|
|
36
45
|
end
|
|
37
46
|
|
|
38
|
-
#
|
|
47
|
+
# Soft-delete every matching record, wrapped in a transaction so the batch is atomic.
|
|
48
|
+
def soft_delete_all
|
|
49
|
+
transaction { all.each(&:soft_delete!) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Override destroy_all to soft delete. Kept for backwards compatibility, but prefer the
|
|
53
|
+
# explicit `soft_delete_all` — silently redefining a standard AR method is a known footgun.
|
|
39
54
|
def destroy_all
|
|
40
|
-
|
|
55
|
+
soft_delete_all
|
|
41
56
|
end
|
|
42
57
|
|
|
43
58
|
# Provide really_destroy_all to hard delete all records
|
|
@@ -45,9 +60,9 @@ module ConcernsOnRails
|
|
|
45
60
|
unscoped.delete_all
|
|
46
61
|
end
|
|
47
62
|
|
|
48
|
-
# Restore every soft-deleted record (mirror of
|
|
63
|
+
# Restore every soft-deleted record, atomically (mirror of soft_delete_all).
|
|
49
64
|
def restore_all
|
|
50
|
-
soft_deleted.each(&:restore!)
|
|
65
|
+
transaction { soft_deleted.each(&:restore!) }
|
|
51
66
|
end
|
|
52
67
|
end
|
|
53
68
|
|
|
@@ -60,26 +75,34 @@ module ConcernsOnRails
|
|
|
60
75
|
def soft_delete!
|
|
61
76
|
return true if deleted?
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
result = false
|
|
79
|
+
# Wrap the timestamp change and its hooks in a transaction so a raising
|
|
80
|
+
# before/after hook rolls the change back instead of leaving a half-applied state.
|
|
81
|
+
transaction do
|
|
82
|
+
before_soft_delete
|
|
83
|
+
result = if self.class.soft_delete_touch
|
|
84
|
+
update(self.class.soft_delete_field => Time.zone.now)
|
|
85
|
+
else
|
|
86
|
+
update_column(self.class.soft_delete_field, Time.zone.now)
|
|
87
|
+
end
|
|
88
|
+
after_soft_delete if result
|
|
89
|
+
end
|
|
70
90
|
result
|
|
71
91
|
end
|
|
72
92
|
|
|
73
93
|
def restore!
|
|
74
94
|
return true unless deleted?
|
|
75
95
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
result = false
|
|
97
|
+
transaction do
|
|
98
|
+
before_restore
|
|
99
|
+
result = if self.class.soft_delete_touch
|
|
100
|
+
update(self.class.soft_delete_field => nil)
|
|
101
|
+
else
|
|
102
|
+
update_column(self.class.soft_delete_field, nil)
|
|
103
|
+
end
|
|
104
|
+
after_restore if result
|
|
105
|
+
end
|
|
83
106
|
result
|
|
84
107
|
end
|
|
85
108
|
|
|
@@ -34,14 +34,16 @@ module ConcernsOnRails
|
|
|
34
34
|
class_methods do
|
|
35
35
|
include ConcernsOnRails::Support::ColumnGuard
|
|
36
36
|
|
|
37
|
-
# Define sortable field and direction
|
|
37
|
+
# Define sortable field and direction.
|
|
38
38
|
# Example:
|
|
39
39
|
# sortable_by :position
|
|
40
40
|
# sortable_by position: :asc
|
|
41
41
|
# sortable_by position: :desc
|
|
42
42
|
#
|
|
43
43
|
# sortable_by :position, use_acts_as_list: false
|
|
44
|
-
|
|
44
|
+
# sortable_by :position, scope: :list_id # independent ordering within each list
|
|
45
|
+
# sortable_by :position, add_new_at: :top # new records go to the top of the list
|
|
46
|
+
def sortable_by(field_config = nil, use_acts_as_list: true, scope: nil, add_new_at: nil, **field_options)
|
|
45
47
|
field_config = field_options if field_config.nil? && field_options.any?
|
|
46
48
|
|
|
47
49
|
# parse field_config
|
|
@@ -56,7 +58,14 @@ module ConcernsOnRails
|
|
|
56
58
|
|
|
57
59
|
ensure_columns!("ConcernsOnRails::Models::Sortable", sortable_field)
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
return unless use_acts_as_list
|
|
62
|
+
|
|
63
|
+
# Thread acts_as_list's own options through (scope: for per-group ordering,
|
|
64
|
+
# add_new_at: for where freshly-inserted rows land).
|
|
65
|
+
list_options = { column: sortable_field }
|
|
66
|
+
list_options[:scope] = scope unless scope.nil?
|
|
67
|
+
list_options[:add_new_at] = add_new_at unless add_new_at.nil?
|
|
68
|
+
acts_as_list(list_options)
|
|
60
69
|
end
|
|
61
70
|
|
|
62
71
|
private
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Models
|
|
5
|
+
# Lightweight, dependency-free tagging over a single string column.
|
|
6
|
+
# Tags are stored delimiter-joined in one column — no join tables, no
|
|
7
|
+
# tagging engine — so it works on any database, including SQLite.
|
|
8
|
+
#
|
|
9
|
+
# class Article < ApplicationRecord
|
|
10
|
+
# include ConcernsOnRails::Taggable
|
|
11
|
+
#
|
|
12
|
+
# taggable_by :tags # default column :tags
|
|
13
|
+
# # taggable_by :skills, downcase: true # custom column, case-folded
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# a = Article.new
|
|
17
|
+
# a.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
|
|
18
|
+
# a.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
|
|
19
|
+
# a.add_tags("api"); a.remove_tags("Rails")
|
|
20
|
+
# a.tagged_with?("ruby") # membership predicate
|
|
21
|
+
# a.save!
|
|
22
|
+
#
|
|
23
|
+
# Article.tagged_with("ruby", "rails") # records carrying BOTH tags
|
|
24
|
+
# Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
|
|
25
|
+
# Article.all_tags # sorted unique tags in use
|
|
26
|
+
#
|
|
27
|
+
# Notes:
|
|
28
|
+
# * Matching is boundary-safe ("rail" does not match "rails").
|
|
29
|
+
# * A tag must not contain the delimiter (default ",").
|
|
30
|
+
# * Reach for acts-as-taggable-on when you need tag contexts, ownership,
|
|
31
|
+
# tag counts/clouds, or polymorphic tags shared across models.
|
|
32
|
+
module Taggable
|
|
33
|
+
extend ActiveSupport::Concern
|
|
34
|
+
|
|
35
|
+
LABEL = "ConcernsOnRails::Models::Taggable".freeze
|
|
36
|
+
DEFAULT_FIELD = :tags
|
|
37
|
+
DEFAULT_DELIMITER = ",".freeze
|
|
38
|
+
|
|
39
|
+
included do
|
|
40
|
+
class_attribute :taggable_field, instance_accessor: false, default: DEFAULT_FIELD
|
|
41
|
+
class_attribute :taggable_delimiter, instance_accessor: false, default: DEFAULT_DELIMITER
|
|
42
|
+
class_attribute :taggable_downcase, instance_accessor: false, default: false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Real module (not `class_methods do`) so the private query helpers live
|
|
46
|
+
# under a single `private`. ActiveSupport::Concern auto-extends ClassMethods.
|
|
47
|
+
module ClassMethods
|
|
48
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
49
|
+
|
|
50
|
+
# Configure the tag column. See the module docs for the DSL.
|
|
51
|
+
def taggable_by(field = DEFAULT_FIELD, delimiter: DEFAULT_DELIMITER, downcase: false)
|
|
52
|
+
self.taggable_field = field.to_sym
|
|
53
|
+
self.taggable_delimiter = delimiter.to_s
|
|
54
|
+
self.taggable_downcase = downcase
|
|
55
|
+
ensure_columns!(LABEL, taggable_field)
|
|
56
|
+
|
|
57
|
+
before_validation :taggable_normalize!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Records carrying the given tags. `any: true` matches ANY tag (OR);
|
|
61
|
+
# the default requires ALL tags (AND). Returns a chainable relation.
|
|
62
|
+
def tagged_with(*names, any: false)
|
|
63
|
+
tags = taggable_clean_all(names)
|
|
64
|
+
return all if tags.empty?
|
|
65
|
+
|
|
66
|
+
clauses = tags.map { |t| taggable_clause(t) }
|
|
67
|
+
sql = clauses.map(&:first).join(any ? " OR " : " AND ")
|
|
68
|
+
where(sql, *clauses.flat_map(&:last))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# All distinct tags currently stored across the table, sorted.
|
|
72
|
+
def all_tags
|
|
73
|
+
pluck(taggable_field).flat_map { |raw| taggable_split(raw) }.uniq.sort
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Split a raw stored column value into a normalized tag array.
|
|
77
|
+
def taggable_split(raw)
|
|
78
|
+
taggable_clean_all(raw.to_s.split(taggable_delimiter))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Normalize a single tag (strip + optional downcase).
|
|
82
|
+
def taggable_clean(tag)
|
|
83
|
+
tag = tag.to_s.strip
|
|
84
|
+
taggable_downcase ? tag.downcase : tag
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def taggable_clean_all(names)
|
|
90
|
+
names.flatten.map { |t| taggable_clean(t) }.reject(&:blank?).uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Boundary-safe match for one tag against the delimiter-joined column.
|
|
94
|
+
# Returns [sql_fragment, [bind_params...]]. An explicit ESCAPE clause makes
|
|
95
|
+
# the backslash escaping below work on every adapter (SQLite has no default
|
|
96
|
+
# LIKE escape), so a tag containing `_` or `%` matches literally.
|
|
97
|
+
def taggable_clause(tag)
|
|
98
|
+
column = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(taggable_field)}"
|
|
99
|
+
delim = taggable_delimiter
|
|
100
|
+
escaped = taggable_escape_like(tag)
|
|
101
|
+
esc = " ESCAPE '\\'"
|
|
102
|
+
["(#{column} = ? OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc})",
|
|
103
|
+
[tag, "#{escaped}#{delim}%", "%#{delim}#{escaped}", "%#{delim}#{escaped}#{delim}%"]]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Treat the user's tag as a LIKE literal: %, _ and \ are not wildcards.
|
|
107
|
+
def taggable_escape_like(str)
|
|
108
|
+
str.gsub(/[\\%_]/) { |char| "\\#{char}" }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ---- instance methods ----
|
|
113
|
+
|
|
114
|
+
def tag_list
|
|
115
|
+
self.class.taggable_split(self[self.class.taggable_field])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def tag_list=(value)
|
|
119
|
+
tags = taggable_coerce(value)
|
|
120
|
+
self[self.class.taggable_field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_tags(*names)
|
|
124
|
+
self.tag_list = tag_list + names.flatten.map { |t| self.class.taggable_clean(t) }
|
|
125
|
+
tag_list
|
|
126
|
+
end
|
|
127
|
+
alias add_tag add_tags
|
|
128
|
+
|
|
129
|
+
def remove_tags(*names)
|
|
130
|
+
drop = names.flatten.map { |t| self.class.taggable_clean(t) }
|
|
131
|
+
self.tag_list = tag_list.reject { |t| drop.include?(t) }
|
|
132
|
+
tag_list
|
|
133
|
+
end
|
|
134
|
+
alias remove_tag remove_tags
|
|
135
|
+
|
|
136
|
+
def tagged_with?(tag)
|
|
137
|
+
tag_list.include?(self.class.taggable_clean(tag))
|
|
138
|
+
end
|
|
139
|
+
alias has_tag? tagged_with?
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# before_validation hook — re-normalize whatever sits in the column, covering
|
|
144
|
+
# direct `record.tags = "..."` assignment, not just the tag_list= setter.
|
|
145
|
+
def taggable_normalize!
|
|
146
|
+
field = self.class.taggable_field
|
|
147
|
+
raw = self[field]
|
|
148
|
+
return if raw.nil?
|
|
149
|
+
|
|
150
|
+
tags = self.class.taggable_split(raw)
|
|
151
|
+
self[field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def taggable_coerce(value)
|
|
155
|
+
items = value.is_a?(Array) ? value : value.to_s.split(self.class.taggable_delimiter)
|
|
156
|
+
items.map { |t| self.class.taggable_clean(t) }.reject(&:blank?).uniq
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -28,6 +28,7 @@ require "concerns_on_rails/models/tokenizable"
|
|
|
28
28
|
require "concerns_on_rails/models/stateable"
|
|
29
29
|
require "concerns_on_rails/models/addressable"
|
|
30
30
|
require "concerns_on_rails/models/sequenceable"
|
|
31
|
+
require "concerns_on_rails/models/taggable"
|
|
31
32
|
|
|
32
33
|
# Controller concerns
|
|
33
34
|
require "concerns_on_rails/controllers/paginatable"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.11.
|
|
4
|
+
version: 1.11.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
@@ -90,6 +90,7 @@ files:
|
|
|
90
90
|
- lib/concerns_on_rails/models/soft_deletable.rb
|
|
91
91
|
- lib/concerns_on_rails/models/sortable.rb
|
|
92
92
|
- lib/concerns_on_rails/models/stateable.rb
|
|
93
|
+
- lib/concerns_on_rails/models/taggable.rb
|
|
93
94
|
- lib/concerns_on_rails/models/tokenizable.rb
|
|
94
95
|
- lib/concerns_on_rails/support/address_data.rb
|
|
95
96
|
- lib/concerns_on_rails/support/column_guard.rb
|