concerns_on_rails 1.8.2 → 1.9.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 +14 -0
- data/README.md +157 -13
- data/lib/concerns_on_rails/controllers/includable.rb +85 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/activatable.rb +3 -5
- data/lib/concerns_on_rails/models/expirable.rb +3 -4
- data/lib/concerns_on_rails/models/hashable.rb +4 -5
- data/lib/concerns_on_rails/models/normalizable.rb +3 -7
- data/lib/concerns_on_rails/models/publishable.rb +40 -5
- data/lib/concerns_on_rails/models/schedulable.rb +4 -5
- data/lib/concerns_on_rails/models/searchable.rb +58 -15
- data/lib/concerns_on_rails/models/sluggable.rb +19 -9
- data/lib/concerns_on_rails/models/soft_deletable.rb +13 -5
- data/lib/concerns_on_rails/models/sortable.rb +4 -13
- data/lib/concerns_on_rails/models/stateable.rb +165 -0
- data/lib/concerns_on_rails/models/tokenizable.rb +8 -14
- data/lib/concerns_on_rails/support/column_guard.rb +32 -0
- data/lib/concerns_on_rails/support/random_value.rb +16 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +7 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d01db047d91e470c1c2bb03f6fbe95be1eea7592199d9a548b6ac5dbacc84452
|
|
4
|
+
data.tar.gz: f47e6149addbee52badc7ae4d56fbc66c636adc75d5a32f7742a57d18eda1cd8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a856b89dd65fc126612d33aa276cfe49949484594a6d9c1cca2af01e91474883b1eb170e3f36647a125375ed645914cff6a1564cac3f955e2d7066a7c3c0e37
|
|
7
|
+
data.tar.gz: 2056b850ffddb607084f672018498b4f366a0ab7c0f97bf22e806355031eae146c256f2f62c4bc3ef5e769ee75a3bd05d2bfc56864a5994ea9b204a1a9b730ab
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.9.0 (2026-05-25)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Models::Stateable**: Lightweight string-backed state machine — predicates (`draft?`, `published?`), scopes (`Article.draft`), direct setters (`published!`), guarded transitions (`publish!` / `may_publish?`) with optional `transitions:` config, generic `transition_to!`, and a configurable default applied via `after_initialize`. Supports `prefix:` / `suffix:` to avoid name clashes. Raises `Stateable::InvalidTransition` for disallowed state changes.
|
|
7
|
+
- **Controllers::Includable**: Whitelisted association sideloading + sparse fieldsets for JSON APIs. `includable :author, :comments, fields: { articles: %i[id title] }` declares an allow-list; `with_includes(relation)` applies only the requested, permitted associations; `requested_includes` / `requested_fields` return sanitized values to pass directly to serializers.
|
|
8
|
+
- **Models::SoftDeletable**: New scopes `with_deleted` (all records including deleted), `only_deleted` (alias for `soft_deleted`), `deleted_within(duration)` (recently deleted in a time window); new class method `restore_all` (bulk-restores all soft-deleted records).
|
|
9
|
+
- **Models::Publishable**: New scopes `scheduled` (future `published_at`) and `draft` (nil `published_at`); predicates `scheduled?` / `draft?`; mutator `publish_at!(time)` for scheduling a future publish; opt-in `publishable_by :published_at, default_scope: true` to hide unpublished records from `.all` (default is off).
|
|
10
|
+
- **Models::Searchable**: New options — `mode: :all` (AND all whitespace-separated terms across fields instead of OR), `match: :prefix` (anchors at start) / `match: :exact` (full match) / `match: :contains` (default, substring); option validation raises `ArgumentError` for unknown values.
|
|
11
|
+
- **Models::Sluggable**: New options — `history: true` (keeps slug history via the `friendly_id_slugs` table so old slugs still resolve) and `scope: :account_id` (slug uniqueness scoped to a foreign-key column). Both delegate to `friendly_id`'s built-in `:history` / `:scoped` modules.
|
|
12
|
+
|
|
13
|
+
### Internal
|
|
14
|
+
- Extracted duplicated column-existence checks from all 11 model concerns into `ConcernsOnRails::Support::ColumnGuard`. Error messages are now unified: `"<Concern>: '<field>' does not exist in the database (table: <table>)"` — the `/does not exist/` substring is preserved across all concerns.
|
|
15
|
+
- Extracted duplicated random-value generation (`Hashable` `:custom` branch + `Tokenizable`) into `ConcernsOnRails::Support::RandomValue`. No behavior change.
|
|
16
|
+
|
|
3
17
|
## 1.8.2 (2026-05-22)
|
|
4
18
|
|
|
5
19
|
### Internal
|
data/README.md
CHANGED
|
@@ -36,12 +36,14 @@ Article.published.without_deleted.find("hello-world")
|
|
|
36
36
|
- [Searchable](#-searchable) — LIKE/ILIKE search across configured columns
|
|
37
37
|
- [Activatable](#-activatable) — boolean active/inactive toggle
|
|
38
38
|
- [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
|
|
39
|
+
- [Stateable](#-stateable) — lightweight string-backed state machine
|
|
39
40
|
- **Controller concerns**
|
|
40
41
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
41
42
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
42
43
|
- [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
|
|
43
44
|
- [Respondable](#-respondable) — standardized JSON envelopes
|
|
44
45
|
- [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
|
|
46
|
+
- [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
|
|
45
47
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
46
48
|
- [Development](#-development)
|
|
47
49
|
- [Contributing](#-contributing)
|
|
@@ -51,7 +53,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
51
53
|
|
|
52
54
|
## ✨ Why this gem?
|
|
53
55
|
|
|
54
|
-
- **
|
|
56
|
+
- **Twelve model concerns + six controller concerns**, all production-ready
|
|
55
57
|
- **One include, one macro** — no boilerplate, no glue code
|
|
56
58
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
57
59
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -64,7 +66,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
64
66
|
Add to your application's `Gemfile`:
|
|
65
67
|
|
|
66
68
|
```ruby
|
|
67
|
-
gem "concerns_on_rails", "~> 1.
|
|
69
|
+
gem "concerns_on_rails", "~> 1.9"
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
Or pull the latest from GitHub:
|
|
@@ -145,10 +147,23 @@ post.slug # => "hello-world" (regenerates on title change)
|
|
|
145
147
|
Post.friendly.find("hello-world")
|
|
146
148
|
```
|
|
147
149
|
|
|
150
|
+
**Options**
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# Keep old slugs resolvable after a title change (needs a friendly_id_slugs migration)
|
|
154
|
+
sluggable_by :title, history: true
|
|
155
|
+
Post.friendly.find("old-slug") # still resolves to the renamed post
|
|
156
|
+
|
|
157
|
+
# Unique slug only within a scope column (same slug allowed in different accounts)
|
|
158
|
+
sluggable_by :title, scope: :account_id
|
|
159
|
+
```
|
|
160
|
+
|
|
148
161
|
**Notes**
|
|
149
162
|
- Schema must have a `slug` column (string).
|
|
163
|
+
- `history: true` requires a `friendly_id_slugs` table — generate with `rails generate friendly_id` or add a manual migration.
|
|
164
|
+
- `scope: :col` requires `col` to exist in the same table.
|
|
150
165
|
- Falls back to `to_s` if the configured source field doesn't respond.
|
|
151
|
-
- Uses friendly_id's `:slugged`
|
|
166
|
+
- Uses friendly_id's `:slugged` (+ optionally `:history`, `:scoped`) strategies under the hood.
|
|
152
167
|
|
|
153
168
|
---
|
|
154
169
|
|
|
@@ -201,11 +216,29 @@ article.unpublish!
|
|
|
201
216
|
|
|
202
217
|
Article.published # WHERE published_at <= NOW()
|
|
203
218
|
Article.unpublished # WHERE published_at IS NULL OR published_at > NOW()
|
|
219
|
+
Article.scheduled # WHERE published_at > NOW() (future-dated — not live yet)
|
|
220
|
+
Article.draft # WHERE published_at IS NULL (no date set at all)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Scheduling**
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
article.publish_at!(1.week.from_now) # sets published_at to a future time
|
|
227
|
+
article.scheduled? # => true (not live yet)
|
|
228
|
+
article.draft? # => false
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Opt-in default scope**
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
publishable_by :published_at, default_scope: true
|
|
235
|
+
# Article.all now returns only published records automatically
|
|
236
|
+
# Article.unscoped reaches everything
|
|
204
237
|
```
|
|
205
238
|
|
|
206
239
|
**Notes**
|
|
207
|
-
- "Published" means `published_at` is set **and** in the past — so
|
|
208
|
-
- No `default_scope` is added; chain `.published` explicitly.
|
|
240
|
+
- "Published" means `published_at` is set **and** in the past — so future-dated posts stay unpublished until their time arrives.
|
|
241
|
+
- No `default_scope` is added by default; chain `.published` explicitly (or opt in with `default_scope: true`).
|
|
209
242
|
|
|
210
243
|
---
|
|
211
244
|
|
|
@@ -232,11 +265,14 @@ user.really_delete! # bypasses callbacks, hard deletes from DB
|
|
|
232
265
|
**Scopes**
|
|
233
266
|
|
|
234
267
|
```ruby
|
|
235
|
-
User.active
|
|
236
|
-
User.without_deleted
|
|
237
|
-
User.soft_deleted
|
|
238
|
-
User.
|
|
239
|
-
User.
|
|
268
|
+
User.active # alias of .without_deleted — non-deleted records
|
|
269
|
+
User.without_deleted # same
|
|
270
|
+
User.soft_deleted # only deleted records (timestamp set)
|
|
271
|
+
User.only_deleted # alias for .soft_deleted
|
|
272
|
+
User.with_deleted # all records — deleted + non-deleted
|
|
273
|
+
User.deleted_within(7.days) # soft-deleted within the last 7 days
|
|
274
|
+
User.all # default scope: non-deleted only
|
|
275
|
+
User.unscoped # everything (deleted + non-deleted)
|
|
240
276
|
```
|
|
241
277
|
|
|
242
278
|
**Bulk operations**
|
|
@@ -244,6 +280,7 @@ User.unscoped # everything (deleted + non-deleted)
|
|
|
244
280
|
```ruby
|
|
245
281
|
User.destroy_all # soft-deletes all matching records
|
|
246
282
|
User.really_destroy_all # hard-deletes all matching records
|
|
283
|
+
User.restore_all # restores all soft-deleted records
|
|
247
284
|
```
|
|
248
285
|
|
|
249
286
|
**Lifecycle hooks** — override these methods on the model:
|
|
@@ -449,11 +486,25 @@ Article.search("") # no-op — returns the full relation
|
|
|
449
486
|
Article.search("foo").where(state: 1) # chainable like any scope
|
|
450
487
|
```
|
|
451
488
|
|
|
489
|
+
**Options**
|
|
490
|
+
|
|
491
|
+
```ruby
|
|
492
|
+
# mode: :any (default) — any term in any field matches (OR)
|
|
493
|
+
# mode: :all — every whitespace-separated term must match somewhere (AND per term, OR across fields)
|
|
494
|
+
searchable_by :title, :body, mode: :all
|
|
495
|
+
Article.search("ruby framework") # title OR body must contain "ruby" AND "framework"
|
|
496
|
+
|
|
497
|
+
# match: :contains (default) — %term% (substring)
|
|
498
|
+
# match: :prefix — term% (starts with)
|
|
499
|
+
# match: :exact — term (full match)
|
|
500
|
+
searchable_by :sku, match: :prefix
|
|
501
|
+
```
|
|
502
|
+
|
|
452
503
|
**Notes**
|
|
453
504
|
- Uses Arel's `matches`, which emits `ILIKE` on Postgres (case-insensitive) and `LIKE` elsewhere.
|
|
454
505
|
- The query is escaped before interpolation — `%`, `_`, and `\` from user input are treated as literals, not wildcards.
|
|
455
|
-
- Blank or nil queries return the relation unchanged so it's safe to drop into a controller pipeline.
|
|
456
|
-
-
|
|
506
|
+
- Blank or nil queries return the relation unchanged, so it's safe to drop into a controller pipeline.
|
|
507
|
+
- Reach for `pg_search` / Elasticsearch when you need ranking, stemming, or full-text indexes.
|
|
457
508
|
|
|
458
509
|
---
|
|
459
510
|
|
|
@@ -526,6 +577,59 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
|
|
|
526
577
|
|
|
527
578
|
---
|
|
528
579
|
|
|
580
|
+
## 🔄 Stateable
|
|
581
|
+
|
|
582
|
+
Lightweight string-backed state machine — the 80% of AASM without the dependency.
|
|
583
|
+
|
|
584
|
+
```ruby
|
|
585
|
+
class Article < ApplicationRecord
|
|
586
|
+
include ConcernsOnRails::Stateable
|
|
587
|
+
|
|
588
|
+
stateable_by :status,
|
|
589
|
+
states: %i[draft pending published archived],
|
|
590
|
+
default: :draft,
|
|
591
|
+
transitions: {
|
|
592
|
+
publish: { from: %i[draft pending], to: :published },
|
|
593
|
+
archive: { to: :archived } # no :from → allowed from any state
|
|
594
|
+
}
|
|
595
|
+
end
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**Generated API**
|
|
599
|
+
|
|
600
|
+
```ruby
|
|
601
|
+
article = Article.new # status defaults to "draft"
|
|
602
|
+
article.draft? # => true (predicate per state)
|
|
603
|
+
article.published? # => false
|
|
604
|
+
|
|
605
|
+
Article.draft # scope: WHERE status = 'draft'
|
|
606
|
+
Article.published # scope: WHERE status = 'published'
|
|
607
|
+
|
|
608
|
+
article.published! # direct setter — updates regardless of current state
|
|
609
|
+
article.publish! # guarded transition — raises InvalidTransition if not allowed
|
|
610
|
+
article.may_publish? # => true (guard check without raising)
|
|
611
|
+
|
|
612
|
+
article.transition_to!(:archived) # generic move to any declared state
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
**Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
stateable_by :state, states: %i[open closed], prefix: true
|
|
619
|
+
# generates: state_open?, state_closed?, state_open!, state_closed!, State.state_open, …
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
**Validation**
|
|
623
|
+
- Raises `ArgumentError` if the column, states, default, or transition config is invalid.
|
|
624
|
+
- Raises `Stateable::InvalidTransition` at runtime for disallowed guarded transitions.
|
|
625
|
+
|
|
626
|
+
**Notes**
|
|
627
|
+
- String-column backed (not integer-backed like Rails enum) — values are stored as-is.
|
|
628
|
+
- States like `active` / `expired` overlap with `Activatable`/`Expirable` scopes — use `prefix:` or `suffix:` to disambiguate.
|
|
629
|
+
- No persistence of transition history; combine with `Publishable` / `Schedulable` for time-based state tracking.
|
|
630
|
+
|
|
631
|
+
---
|
|
632
|
+
|
|
529
633
|
# 🎮 Controller Concerns
|
|
530
634
|
|
|
531
635
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -709,6 +813,46 @@ end
|
|
|
709
813
|
|
|
710
814
|
---
|
|
711
815
|
|
|
816
|
+
## 🔗 Includable
|
|
817
|
+
|
|
818
|
+
Whitelisted association sideloading + sparse fieldsets for JSON APIs — zero arbitrary `.includes` from user input.
|
|
819
|
+
|
|
820
|
+
```ruby
|
|
821
|
+
class ArticlesController < ApplicationController
|
|
822
|
+
include ConcernsOnRails::Controllers::Includable
|
|
823
|
+
|
|
824
|
+
includable :author, :comments,
|
|
825
|
+
fields: { articles: %i[id title published_at], authors: %i[id name] }
|
|
826
|
+
|
|
827
|
+
def index
|
|
828
|
+
render json: with_includes(Article.all),
|
|
829
|
+
include: requested_includes,
|
|
830
|
+
fields: requested_fields
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
**URL params**
|
|
836
|
+
|
|
837
|
+
```
|
|
838
|
+
GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=id,name
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
**API**
|
|
842
|
+
|
|
843
|
+
| Method | What it does |
|
|
844
|
+
|----------------------|--------------------------------------------------------------------------------------------|
|
|
845
|
+
| `with_includes(rel)` | Parses `params[:include]`, intersects with the allow-list, calls `relation.includes(...)` |
|
|
846
|
+
| `requested_includes` | Returns the sanitized `[:author, :comments]` array (pass to `render json:, include:`) |
|
|
847
|
+
| `requested_fields` | Returns `{ articles: [:id, :title] }` sanitized map (pass to your serializer) |
|
|
848
|
+
|
|
849
|
+
**Notes**
|
|
850
|
+
- Non-whitelisted associations are **silently dropped** — no error, no arbitrary eager-loading.
|
|
851
|
+
- Non-whitelisted tables in `params[:fields]` are dropped; non-whitelisted columns within an allowed table are dropped.
|
|
852
|
+
- Pass `requested_fields` to your serializer (e.g. AMS / Blueprinter) — `Includable` itself does not alter the JSON output, only the query.
|
|
853
|
+
|
|
854
|
+
---
|
|
855
|
+
|
|
712
856
|
## 🗂️ Module paths & namespacing
|
|
713
857
|
|
|
714
858
|
Every concern is available under two paths:
|
|
@@ -740,7 +884,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
740
884
|
bundle install # install dev dependencies
|
|
741
885
|
bundle exec rspec # run the test suite
|
|
742
886
|
gem build concerns_on_rails.gemspec # build the gem
|
|
743
|
-
gem install ./concerns_on_rails-1.
|
|
887
|
+
gem install ./concerns_on_rails-1.9.0.gem # install locally
|
|
744
888
|
```
|
|
745
889
|
|
|
746
890
|
The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Controllers
|
|
5
|
+
# Whitelisted association sideloading + sparse fieldsets for JSON APIs.
|
|
6
|
+
# Same allow-list philosophy as Controllers::Sortable: a client can only ask
|
|
7
|
+
# for associations/fields you've explicitly permitted, so `?include=` can
|
|
8
|
+
# never trigger an arbitrary `.includes` (N+1 / data-exposure risk).
|
|
9
|
+
#
|
|
10
|
+
# class ArticlesController < ApplicationController
|
|
11
|
+
# include ConcernsOnRails::Controllers::Includable
|
|
12
|
+
#
|
|
13
|
+
# includable :author, :comments,
|
|
14
|
+
# fields: { articles: %i[id title], authors: %i[id name] }
|
|
15
|
+
#
|
|
16
|
+
# def index
|
|
17
|
+
# render json: with_includes(Article.all),
|
|
18
|
+
# include: requested_includes,
|
|
19
|
+
# fields: requested_fields
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# URL params:
|
|
24
|
+
# ?include=author,comments -> eager-loads only whitelisted associations
|
|
25
|
+
# ?fields[articles]=id,title -> sanitized down to the allowed columns
|
|
26
|
+
#
|
|
27
|
+
# `requested_includes` / `requested_fields` return sanitized values you can
|
|
28
|
+
# hand to your serializer; they never mutate the rendered output themselves.
|
|
29
|
+
module Includable
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
included do
|
|
33
|
+
class_attribute :includable_associations, default: []
|
|
34
|
+
class_attribute :includable_fields, default: {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
# Whitelist sideloadable associations and (optionally) the columns
|
|
39
|
+
# exposable per resource via sparse fieldsets.
|
|
40
|
+
def includable(*associations, fields: {})
|
|
41
|
+
self.includable_associations = associations.map(&:to_sym)
|
|
42
|
+
self.includable_fields = fields.each_with_object({}) do |(table, cols), memo|
|
|
43
|
+
memo[table.to_sym] = Array(cols).map(&:to_sym)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Eager-load only the whitelisted associations requested via ?include=.
|
|
49
|
+
# Returns the relation unchanged when nothing valid was requested.
|
|
50
|
+
def with_includes(relation)
|
|
51
|
+
associations = requested_includes
|
|
52
|
+
associations.empty? ? relation : relation.includes(*associations)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Sanitized association list: requested ∩ allow-list.
|
|
56
|
+
def requested_includes
|
|
57
|
+
requested = params[:include].to_s.split(",").map { |token| token.strip.to_sym }
|
|
58
|
+
requested & self.class.includable_associations
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sanitized sparse fieldsets: { table => [cols] }, each intersected with
|
|
62
|
+
# the allowed columns for that table. Unknown tables/columns are dropped.
|
|
63
|
+
def requested_fields
|
|
64
|
+
raw = params[:fields]
|
|
65
|
+
return {} unless raw.respond_to?(:each_pair)
|
|
66
|
+
|
|
67
|
+
allowed = self.class.includable_fields
|
|
68
|
+
raw.each_with_object({}) do |(table, cols), memo|
|
|
69
|
+
key = table.to_sym
|
|
70
|
+
next unless allowed.key?(key)
|
|
71
|
+
|
|
72
|
+
permitted = split_field_list(cols) & allowed[key]
|
|
73
|
+
memo[key] = permitted unless permitted.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def split_field_list(cols)
|
|
80
|
+
list = cols.is_a?(Array) ? cols : cols.to_s.split(",")
|
|
81
|
+
list.map { |col| col.to_s.strip.to_sym }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -28,13 +28,11 @@ module ConcernsOnRails
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
class_methods do
|
|
31
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
32
|
+
|
|
31
33
|
def activatable_by(field = DEFAULT_FIELD)
|
|
32
34
|
self.activatable_field = field.to_sym
|
|
33
|
-
|
|
34
|
-
unless column_names.include?(activatable_field.to_s)
|
|
35
|
-
raise ArgumentError,
|
|
36
|
-
"ConcernsOnRails::Models::Activatable: activatable_field '#{activatable_field}' does not exist in the database"
|
|
37
|
-
end
|
|
35
|
+
ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field)
|
|
38
36
|
|
|
39
37
|
scope :active, -> { where(activatable_field => true) }
|
|
40
38
|
scope :inactive, -> { where(activatable_field => [false, nil]) }
|
|
@@ -27,16 +27,15 @@ module ConcernsOnRails
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
class_methods do
|
|
30
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
31
|
+
|
|
30
32
|
# Configure the expiry column.
|
|
31
33
|
# Example:
|
|
32
34
|
# expirable_by # uses :expires_at
|
|
33
35
|
# expirable_by :valid_until
|
|
34
36
|
def expirable_by(field = DEFAULT_FIELD)
|
|
35
37
|
self.expirable_field = field.to_sym
|
|
36
|
-
|
|
37
|
-
return if column_names.include?(expirable_field.to_s)
|
|
38
|
-
|
|
39
|
-
raise ArgumentError, "ConcernsOnRails::Models::Expirable: expirable_field '#{expirable_field}' does not exist in the database"
|
|
38
|
+
ensure_columns!("ConcernsOnRails::Models::Expirable", expirable_field)
|
|
40
39
|
end
|
|
41
40
|
end
|
|
42
41
|
|
|
@@ -16,6 +16,8 @@ module ConcernsOnRails
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
class_methods do
|
|
19
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
20
|
+
|
|
19
21
|
# Define hashable field and generation options.
|
|
20
22
|
# Example:
|
|
21
23
|
# hashable_by :token
|
|
@@ -29,6 +31,7 @@ module ConcernsOnRails
|
|
|
29
31
|
self.hashable_length = length.to_i
|
|
30
32
|
self.hashable_alphabet = alphabet
|
|
31
33
|
|
|
34
|
+
ensure_columns!("ConcernsOnRails::Models::Hashable", hashable_field)
|
|
32
35
|
validate_hashable_options!
|
|
33
36
|
before_create :assign_hashable_value
|
|
34
37
|
|
|
@@ -45,17 +48,13 @@ module ConcernsOnRails
|
|
|
45
48
|
when :hex then SecureRandom.hex(hashable_length)
|
|
46
49
|
when :uuid then SecureRandom.uuid
|
|
47
50
|
when :integer then SecureRandom.random_number(10**hashable_length).to_s.rjust(hashable_length, "0").to_i
|
|
48
|
-
when :custom then
|
|
51
|
+
when :custom then ConcernsOnRails::Support::RandomValue.from_alphabet(hashable_alphabet, hashable_length)
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
private
|
|
53
56
|
|
|
54
57
|
def validate_hashable_options!
|
|
55
|
-
unless column_names.include?(hashable_field.to_s)
|
|
56
|
-
raise ArgumentError, "ConcernsOnRails::Models::Hashable: hashable_field '#{hashable_field}' does not exist in the database"
|
|
57
|
-
end
|
|
58
|
-
|
|
59
58
|
unless VALID_TYPES.include?(hashable_type)
|
|
60
59
|
raise ArgumentError,
|
|
61
60
|
"ConcernsOnRails::Models::Hashable: unknown type '#{hashable_type}'. Valid types: #{VALID_TYPES.join(', ')}"
|
|
@@ -22,6 +22,8 @@ module ConcernsOnRails
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
class_methods do
|
|
25
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
26
|
+
|
|
25
27
|
# Declare which fields should be normalized and how.
|
|
26
28
|
# Example:
|
|
27
29
|
# normalizable :email, with: :email
|
|
@@ -31,7 +33,7 @@ module ConcernsOnRails
|
|
|
31
33
|
raise ArgumentError, "ConcernsOnRails::Models::Normalizable: at least one field is required" if fields.empty?
|
|
32
34
|
|
|
33
35
|
normalizer = resolve_normalizer(with)
|
|
34
|
-
|
|
36
|
+
ensure_columns!("ConcernsOnRails::Models::Normalizable", fields)
|
|
35
37
|
self.normalizable_rules = normalizable_rules.merge(fields.to_h { |f| [f.to_sym, normalizer] })
|
|
36
38
|
end
|
|
37
39
|
end
|
|
@@ -39,12 +41,6 @@ module ConcernsOnRails
|
|
|
39
41
|
class_methods do
|
|
40
42
|
private
|
|
41
43
|
|
|
42
|
-
def validate_normalizable_field!(field)
|
|
43
|
-
return if column_names.include?(field.to_s)
|
|
44
|
-
|
|
45
|
-
raise ArgumentError, "ConcernsOnRails::Models::Normalizable: field '#{field}' does not exist in the database"
|
|
46
|
-
end
|
|
47
|
-
|
|
48
44
|
def resolve_normalizer(with)
|
|
49
45
|
case with
|
|
50
46
|
when Symbol
|
|
@@ -10,18 +10,33 @@ module ConcernsOnRails
|
|
|
10
10
|
|
|
11
11
|
scope :published, -> { where(arel_table[publishable_field].lteq(Time.zone.now)) }
|
|
12
12
|
scope :unpublished, lambda {
|
|
13
|
-
|
|
13
|
+
column = arel_table[publishable_field]
|
|
14
|
+
unscope(where: publishable_field).where(column.eq(nil).or(column.gt(Time.zone.now)))
|
|
14
15
|
}
|
|
16
|
+
# Set, but the publish time is still in the future.
|
|
17
|
+
scope :scheduled, -> { unscope(where: publishable_field).where(arel_table[publishable_field].gt(Time.zone.now)) }
|
|
18
|
+
# Never set — a true draft.
|
|
19
|
+
scope :draft, -> { unscope(where: publishable_field).where(publishable_field => nil) }
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
class_methods do
|
|
18
|
-
|
|
23
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
24
|
+
|
|
25
|
+
# Pass `default_scope: true` to hide unpublished records by default
|
|
26
|
+
# (`.all` then returns only published). The negative scopes
|
|
27
|
+
# (.unpublished/.scheduled/.draft) unscope the field, so they still work.
|
|
28
|
+
def publishable_by(field = nil, default_scope: false)
|
|
19
29
|
self.publishable_field = field || :published_at
|
|
30
|
+
ensure_columns!("ConcernsOnRails::Models::Publishable", publishable_field)
|
|
31
|
+
enable_published_default_scope if default_scope
|
|
32
|
+
end
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
private
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
# Routed through a helper so the `default_scope:` keyword doesn't shadow
|
|
37
|
+
# the `default_scope` macro inside `publishable_by`.
|
|
38
|
+
def enable_published_default_scope
|
|
39
|
+
default_scope { published }
|
|
25
40
|
end
|
|
26
41
|
end
|
|
27
42
|
|
|
@@ -56,6 +71,26 @@ module ConcernsOnRails
|
|
|
56
71
|
def unpublished?
|
|
57
72
|
!published?
|
|
58
73
|
end
|
|
74
|
+
|
|
75
|
+
# Set, but the publish time is still in the future.
|
|
76
|
+
def scheduled?
|
|
77
|
+
value = self[self.class.publishable_field]
|
|
78
|
+
return false if value.blank?
|
|
79
|
+
|
|
80
|
+
value.respond_to?(:>) ? value > Time.zone.now : false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Never set — a true draft.
|
|
84
|
+
def draft?
|
|
85
|
+
self[self.class.publishable_field].blank?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Publish at an explicit time. A future time schedules the record.
|
|
89
|
+
# Example:
|
|
90
|
+
# record.publish_at!(1.day.from_now)
|
|
91
|
+
def publish_at!(time)
|
|
92
|
+
update(self.class.publishable_field => time)
|
|
93
|
+
end
|
|
59
94
|
end
|
|
60
95
|
end
|
|
61
96
|
end
|
|
@@ -39,6 +39,8 @@ module ConcernsOnRails
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
class_methods do
|
|
42
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
43
|
+
|
|
42
44
|
# Configure the start/end timestamp columns.
|
|
43
45
|
# Example:
|
|
44
46
|
# schedulable_by # uses :starts_at and :ends_at
|
|
@@ -52,11 +54,8 @@ module ConcernsOnRails
|
|
|
52
54
|
raise ArgumentError, "ConcernsOnRails::Models::Schedulable: at least one of starts_at: or ends_at: must be configured"
|
|
53
55
|
end
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
raise ArgumentError, "ConcernsOnRails::Models::Schedulable: field '#{field}' does not exist in the database"
|
|
59
|
-
end
|
|
57
|
+
ensure_columns!("ConcernsOnRails::Models::Schedulable",
|
|
58
|
+
schedulable_starts_at_field, schedulable_ends_at_field)
|
|
60
59
|
end
|
|
61
60
|
end
|
|
62
61
|
|
|
@@ -7,38 +7,53 @@ module ConcernsOnRails
|
|
|
7
7
|
# class Article < ApplicationRecord
|
|
8
8
|
# include ConcernsOnRails::Searchable
|
|
9
9
|
#
|
|
10
|
-
# searchable_by :title, :body
|
|
10
|
+
# searchable_by :title, :body # defaults below
|
|
11
|
+
# searchable_by :title, :body, mode: :all # every term must match
|
|
12
|
+
# searchable_by :sku, match: :prefix # "abc" -> "abc%"
|
|
13
|
+
# searchable_by :code, match: :exact, case_sensitive: true
|
|
11
14
|
# end
|
|
12
15
|
#
|
|
13
16
|
# Article.search("hello") # WHERE title ILIKE '%hello%' OR body ILIKE '%hello%'
|
|
14
17
|
# Article.search("") # no-op — returns the full relation
|
|
15
18
|
# Article.search("foo").where(...) # chainable like any scope
|
|
16
19
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
+
# Options (all optional; the defaults reproduce a single-term, case-insensitive
|
|
21
|
+
# "contains" search across the given columns):
|
|
22
|
+
# mode: :any (default) treats the whole query as one term;
|
|
23
|
+
# :all splits on whitespace and requires every term to match.
|
|
24
|
+
# match: :contains (default, "%q%"), :prefix ("q%"), or :exact ("q").
|
|
25
|
+
# case_sensitive: false (default) emits ILIKE on Postgres; true emits LIKE.
|
|
26
|
+
#
|
|
27
|
+
# Uses Arel's `matches`. The query is escaped before interpolation, so
|
|
28
|
+
# `%` / `_` / `\` from user input are treated as literals.
|
|
20
29
|
module Searchable
|
|
21
30
|
extend ActiveSupport::Concern
|
|
22
31
|
|
|
23
32
|
LIKE_ESCAPE = "\\".freeze
|
|
24
33
|
LIKE_SPECIAL = /[\\%_]/
|
|
34
|
+
VALID_MODES = %i[any all].freeze
|
|
35
|
+
VALID_MATCHES = %i[contains prefix exact].freeze
|
|
25
36
|
|
|
26
37
|
included do
|
|
27
38
|
class_attribute :searchable_fields, instance_accessor: false, default: []
|
|
39
|
+
class_attribute :searchable_mode, instance_accessor: false, default: :any
|
|
40
|
+
class_attribute :searchable_match, instance_accessor: false, default: :contains
|
|
41
|
+
class_attribute :searchable_case_sensitive, instance_accessor: false, default: false
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
class_methods do
|
|
31
|
-
|
|
45
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
46
|
+
|
|
47
|
+
def searchable_by(*fields, mode: :any, match: :contains, case_sensitive: false)
|
|
32
48
|
raise ArgumentError, "ConcernsOnRails::Models::Searchable: at least one field is required" if fields.empty?
|
|
33
49
|
|
|
34
|
-
fields
|
|
35
|
-
|
|
36
|
-
raise ArgumentError,
|
|
37
|
-
"ConcernsOnRails::Models::Searchable: field '#{field}' does not exist in the database"
|
|
38
|
-
end
|
|
39
|
-
end
|
|
50
|
+
ensure_columns!("ConcernsOnRails::Models::Searchable", fields)
|
|
51
|
+
validate_search_options!(mode, match)
|
|
40
52
|
|
|
41
53
|
self.searchable_fields = fields.map(&:to_sym)
|
|
54
|
+
self.searchable_mode = mode.to_sym
|
|
55
|
+
self.searchable_match = match.to_sym
|
|
56
|
+
self.searchable_case_sensitive = case_sensitive
|
|
42
57
|
|
|
43
58
|
scope :search, ->(query) { search_relation(query) }
|
|
44
59
|
end
|
|
@@ -46,11 +61,39 @@ module ConcernsOnRails
|
|
|
46
61
|
def search_relation(query)
|
|
47
62
|
return all if query.nil? || query.to_s.strip.empty?
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
terms = searchable_mode == :all ? query.to_s.split : [query.to_s]
|
|
65
|
+
terms.reduce(all) { |relation, term| relation.where(search_term_predicate(term)) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class_methods do
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# OR the per-field LIKE predicate for a single term.
|
|
73
|
+
def search_term_predicate(term)
|
|
74
|
+
pattern = search_like_pattern(term)
|
|
75
|
+
predicates = searchable_fields.map do |field|
|
|
76
|
+
arel_table[field].matches(pattern, LIKE_ESCAPE, searchable_case_sensitive)
|
|
77
|
+
end
|
|
78
|
+
predicates.reduce { |memo, predicate| memo.or(predicate) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def search_like_pattern(term)
|
|
82
|
+
escaped = term.to_s.gsub(LIKE_SPECIAL) { |char| "#{LIKE_ESCAPE}#{char}" }
|
|
83
|
+
case searchable_match
|
|
84
|
+
when :prefix then "#{escaped}%"
|
|
85
|
+
when :exact then escaped
|
|
86
|
+
else "%#{escaped}%"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_search_options!(mode, match)
|
|
91
|
+
unless VALID_MODES.include?(mode.to_sym)
|
|
92
|
+
raise ArgumentError, "ConcernsOnRails::Models::Searchable: unknown mode '#{mode}'. Valid modes: #{VALID_MODES.join(', ')}"
|
|
93
|
+
end
|
|
94
|
+
return if VALID_MATCHES.include?(match.to_sym)
|
|
51
95
|
|
|
52
|
-
|
|
53
|
-
where(predicates.reduce { |memo, p| memo.or(p) })
|
|
96
|
+
raise ArgumentError, "ConcernsOnRails::Models::Searchable: unknown match '#{match}'. Valid matches: #{VALID_MATCHES.join(', ')}"
|
|
54
97
|
end
|
|
55
98
|
end
|
|
56
99
|
end
|
|
@@ -29,22 +29,32 @@ module ConcernsOnRails
|
|
|
29
29
|
|
|
30
30
|
# class methods
|
|
31
31
|
class_methods do
|
|
32
|
-
|
|
32
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
33
|
+
|
|
34
|
+
# Define sluggable field, with optional friendly_id features.
|
|
33
35
|
# Example:
|
|
34
36
|
# sluggable_by :wonderful_name
|
|
35
|
-
|
|
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
|
+
def sluggable_by(field, history: false, scope: nil)
|
|
36
40
|
self.sluggable_field = field.to_sym
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
ensure_columns!("ConcernsOnRails::Models::Sluggable", [sluggable_field, scope].compact)
|
|
42
|
+
reconfigure_friendly_id(history: history, scope: scope) if history || scope
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
private
|
|
42
46
|
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
# Re-runs friendly_id with the extra modules. friendly_id merges config
|
|
48
|
+
# across calls, so this layers :history / :scoped onto the base :slugged.
|
|
49
|
+
def reconfigure_friendly_id(history:, scope:)
|
|
50
|
+
modules = [:slugged]
|
|
51
|
+
modules << :history if history
|
|
52
|
+
modules << :scoped if scope
|
|
53
|
+
options = { use: modules }
|
|
54
|
+
options[:scope] = scope if scope
|
|
55
|
+
# friendly_id's second argument is a positional options hash (not kwargs),
|
|
56
|
+
# so pass it positionally to stay correct on both Ruby 2.7 and 3.x.
|
|
57
|
+
friendly_id(:slug_source, options)
|
|
48
58
|
end
|
|
49
59
|
end
|
|
50
60
|
|
|
@@ -14,22 +14,25 @@ module ConcernsOnRails
|
|
|
14
14
|
scope :active, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
|
|
15
15
|
scope :without_deleted, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
|
|
16
16
|
scope :soft_deleted, -> { unscope(where: soft_delete_field).where.not(soft_delete_field => nil) }
|
|
17
|
+
scope :only_deleted, -> { soft_deleted }
|
|
18
|
+
# `with_deleted` peels off the default scope so deleted + non-deleted are both returned.
|
|
19
|
+
scope :with_deleted, -> { unscope(where: soft_delete_field) }
|
|
20
|
+
# Records soft-deleted within the last `duration` (e.g. `deleted_within(7.days)`).
|
|
21
|
+
scope :deleted_within, ->(duration) { soft_deleted.where(soft_delete_field => duration.ago..) }
|
|
17
22
|
# Optionally, uncomment to hide deleted by default:
|
|
18
23
|
default_scope { without_deleted }
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
class_methods do
|
|
27
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
28
|
+
|
|
22
29
|
# Define soft delete field and options
|
|
23
30
|
# Example:
|
|
24
31
|
# soft_deletable_by :deleted_at, touch: false
|
|
25
32
|
def soft_deletable_by(field = nil, touch: true)
|
|
26
33
|
self.soft_delete_field = field || :deleted_at
|
|
27
34
|
self.soft_delete_touch = touch
|
|
28
|
-
|
|
29
|
-
return if column_names.include?(soft_delete_field.to_s)
|
|
30
|
-
|
|
31
|
-
raise ArgumentError,
|
|
32
|
-
"ConcernsOnRails::Models::SoftDeletable: soft_delete_field '#{soft_delete_field}' does not exist in the database"
|
|
35
|
+
ensure_columns!("ConcernsOnRails::Models::SoftDeletable", soft_delete_field)
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# Override destroy_all to perform soft delete on all records
|
|
@@ -41,6 +44,11 @@ module ConcernsOnRails
|
|
|
41
44
|
def really_destroy_all
|
|
42
45
|
unscoped.delete_all
|
|
43
46
|
end
|
|
47
|
+
|
|
48
|
+
# Restore every soft-deleted record (mirror of the destroy_all override).
|
|
49
|
+
def restore_all
|
|
50
|
+
soft_deleted.each(&:restore!)
|
|
51
|
+
end
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
# Soft delete hooks
|
|
@@ -24,11 +24,7 @@ module ConcernsOnRails
|
|
|
24
24
|
|
|
25
25
|
# we cannot use acts_as_list here
|
|
26
26
|
default_scope do
|
|
27
|
-
|
|
28
|
-
raise ArgumentError,
|
|
29
|
-
"#{name}: '#{sortable_field}' column not found. Call `sortable_by :your_column` to configure the sort field."
|
|
30
|
-
end
|
|
31
|
-
|
|
27
|
+
ensure_columns!("ConcernsOnRails::Models::Sortable", sortable_field)
|
|
32
28
|
order(sortable_field => sortable_direction)
|
|
33
29
|
end
|
|
34
30
|
end
|
|
@@ -36,6 +32,8 @@ module ConcernsOnRails
|
|
|
36
32
|
# class methods
|
|
37
33
|
# Example: Task.sortable_by(priority: :asc)
|
|
38
34
|
class_methods do
|
|
35
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
36
|
+
|
|
39
37
|
# Define sortable field and direction
|
|
40
38
|
# Example:
|
|
41
39
|
# sortable_by :position
|
|
@@ -56,7 +54,7 @@ module ConcernsOnRails
|
|
|
56
54
|
self.sortable_field = field
|
|
57
55
|
self.sortable_direction = direction
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
ensure_columns!("ConcernsOnRails::Models::Sortable", sortable_field)
|
|
60
58
|
|
|
61
59
|
acts_as_list column: sortable_field if use_acts_as_list
|
|
62
60
|
end
|
|
@@ -74,13 +72,6 @@ module ConcernsOnRails
|
|
|
74
72
|
[config.to_sym, :asc]
|
|
75
73
|
end
|
|
76
74
|
end
|
|
77
|
-
|
|
78
|
-
# Validate sortable_field exists in database
|
|
79
|
-
def validate_sortable_field!
|
|
80
|
-
return if column_names.include?(sortable_field.to_s)
|
|
81
|
-
|
|
82
|
-
raise ArgumentError, "ConcernsOnRails::Models::Sortable: sortable_field '#{sortable_field}' does not exist in the database"
|
|
83
|
-
end
|
|
84
75
|
end
|
|
85
76
|
end
|
|
86
77
|
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Models
|
|
5
|
+
# Lightweight, string-backed state machine — the common 80% of a state
|
|
6
|
+
# machine without an AASM-sized dependency.
|
|
7
|
+
#
|
|
8
|
+
# class Article < ApplicationRecord
|
|
9
|
+
# include ConcernsOnRails::Stateable
|
|
10
|
+
#
|
|
11
|
+
# stateable_by :status,
|
|
12
|
+
# states: %i[draft pending published archived],
|
|
13
|
+
# default: :draft,
|
|
14
|
+
# transitions: {
|
|
15
|
+
# publish: { from: %i[draft pending], to: :published },
|
|
16
|
+
# archive: { to: :archived } # :from omitted => any state
|
|
17
|
+
# }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Generates, for each state (method names honor prefix:/suffix:):
|
|
21
|
+
# * predicate — article.draft? => status == "draft"
|
|
22
|
+
# * scope — Article.draft => where(status: "draft")
|
|
23
|
+
# * setter — article.published! => update!(status: "published") (unguarded)
|
|
24
|
+
#
|
|
25
|
+
# And for each declared transition:
|
|
26
|
+
# * event! — article.publish! => guarded; raises InvalidTransition from a bad state
|
|
27
|
+
# * guard? — article.may_publish? => whether the transition is allowed now
|
|
28
|
+
#
|
|
29
|
+
# Plus a generic `transition_to!(state)`.
|
|
30
|
+
#
|
|
31
|
+
# Options for stateable_by: default:, transitions:, prefix:, suffix:
|
|
32
|
+
# (prefix:/suffix: take `true` to use the field name, or a literal string/symbol).
|
|
33
|
+
#
|
|
34
|
+
# Notes:
|
|
35
|
+
# * String columns only (store the state name) — not integer-backed like Rails enum.
|
|
36
|
+
# * A state named like an AR method (`new`, `valid`) or a concern scope
|
|
37
|
+
# (`active`, `expired`) will clash — use prefix:/suffix: to disambiguate.
|
|
38
|
+
module Stateable
|
|
39
|
+
extend ActiveSupport::Concern
|
|
40
|
+
|
|
41
|
+
LABEL = "ConcernsOnRails::Models::Stateable".freeze
|
|
42
|
+
|
|
43
|
+
# Raised when a guarded transition is attempted from a disallowed state.
|
|
44
|
+
class InvalidTransition < StandardError; end
|
|
45
|
+
|
|
46
|
+
included do
|
|
47
|
+
class_attribute :stateable_field, instance_accessor: false
|
|
48
|
+
class_attribute :stateable_states, instance_accessor: false, default: []
|
|
49
|
+
class_attribute :stateable_default, instance_accessor: false
|
|
50
|
+
class_attribute :stateable_transitions, instance_accessor: false, default: {}
|
|
51
|
+
class_attribute :stateable_prefix, instance_accessor: false
|
|
52
|
+
class_attribute :stateable_suffix, instance_accessor: false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Move to any declared state by name, bypassing transition guards.
|
|
56
|
+
def transition_to!(state)
|
|
57
|
+
state = state.to_sym
|
|
58
|
+
raise InvalidTransition, "#{LABEL}: '#{state}' is not a declared state" unless self.class.stateable_states.include?(state)
|
|
59
|
+
|
|
60
|
+
update!(self.class.stateable_field => state.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Defined as a real module (not `class_methods do`) so all the private
|
|
64
|
+
# builder helpers live under a single `private` and aren't constrained by
|
|
65
|
+
# Metrics/BlockLength. ActiveSupport::Concern auto-extends `ClassMethods`.
|
|
66
|
+
module ClassMethods
|
|
67
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
68
|
+
|
|
69
|
+
# Configure the state column. See the module docs for the full DSL.
|
|
70
|
+
def stateable_by(field, states:, **options)
|
|
71
|
+
stateable_configure!(field, states, options)
|
|
72
|
+
stateable_validate!
|
|
73
|
+
stateable_define_states
|
|
74
|
+
stateable_define_transitions
|
|
75
|
+
stateable_apply_default
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def stateable_configure!(field, states, options)
|
|
81
|
+
self.stateable_field = field.to_sym
|
|
82
|
+
self.stateable_states = Array(states).map(&:to_sym)
|
|
83
|
+
self.stateable_default = options[:default]&.to_sym
|
|
84
|
+
self.stateable_transitions = options[:transitions] || {}
|
|
85
|
+
self.stateable_prefix = stateable_affix(options[:prefix])
|
|
86
|
+
self.stateable_suffix = stateable_affix(options[:suffix])
|
|
87
|
+
ensure_columns!(LABEL, stateable_field)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `true` => use the field name; a string/symbol => use it literally; else none.
|
|
91
|
+
def stateable_affix(option)
|
|
92
|
+
return nil unless option
|
|
93
|
+
|
|
94
|
+
option == true ? stateable_field.to_s : option.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stateable_method_name(base)
|
|
98
|
+
[stateable_prefix, base, stateable_suffix].compact.join("_")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def stateable_validate!
|
|
102
|
+
raise ArgumentError, "#{LABEL}: states: cannot be empty" if stateable_states.empty?
|
|
103
|
+
|
|
104
|
+
if stateable_default && !stateable_states.include?(stateable_default)
|
|
105
|
+
raise ArgumentError, "#{LABEL}: default '#{stateable_default}' is not a declared state"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
stateable_transitions.each { |event, config| stateable_validate_transition!(event, config) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stateable_validate_transition!(event, config)
|
|
112
|
+
raise ArgumentError, "#{LABEL}: transition '#{event}' must declare :to" unless config[:to]
|
|
113
|
+
|
|
114
|
+
unknown = (Array(config[:from]) + [config[:to]]).map(&:to_sym) - stateable_states
|
|
115
|
+
raise ArgumentError, "#{LABEL}: transition '#{event}' references unknown states: #{unknown.join(', ')}" if unknown.any?
|
|
116
|
+
|
|
117
|
+
return unless stateable_states.include?(event.to_sym)
|
|
118
|
+
|
|
119
|
+
raise ArgumentError, "#{LABEL}: transition '#{event}' clashes with the same-named state setter; use prefix:/suffix:"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def stateable_define_states
|
|
123
|
+
field = stateable_field
|
|
124
|
+
stateable_states.each do |state|
|
|
125
|
+
value = state.to_s
|
|
126
|
+
name = stateable_method_name(state)
|
|
127
|
+
scope name, -> { where(field => value) }
|
|
128
|
+
define_method("#{name}?") { self[field].to_s == value }
|
|
129
|
+
define_method("#{name}!") { update!(field => value) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def stateable_define_transitions
|
|
134
|
+
field = stateable_field
|
|
135
|
+
stateable_transitions.each do |event, config|
|
|
136
|
+
from = Array(config[:from]).map(&:to_s)
|
|
137
|
+
to = config.fetch(:to).to_s
|
|
138
|
+
name = stateable_method_name(event)
|
|
139
|
+
define_method("may_#{name}?") { from.empty? || from.include?(self[field].to_s) }
|
|
140
|
+
define_method("#{name}!") { stateable_perform_transition!(field, to, from, event) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def stateable_apply_default
|
|
145
|
+
return unless stateable_default
|
|
146
|
+
|
|
147
|
+
field = stateable_field
|
|
148
|
+
default = stateable_default.to_s
|
|
149
|
+
after_initialize { self[field] = default if new_record? && self[field].blank? }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Instance-level guarded transition body, shared by every `<event>!`.
|
|
156
|
+
def stateable_perform_transition!(field, to, from, event)
|
|
157
|
+
unless from.empty? || from.include?(self[field].to_s)
|
|
158
|
+
raise InvalidTransition, "#{self.class.name}: cannot #{event} from '#{self[field]}'"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
update!(field => to)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -38,6 +38,8 @@ module ConcernsOnRails
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
class_methods do
|
|
41
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
42
|
+
|
|
41
43
|
# Configure a tokenizable field.
|
|
42
44
|
#
|
|
43
45
|
# Options:
|
|
@@ -48,7 +50,8 @@ module ConcernsOnRails
|
|
|
48
50
|
type = type.to_sym
|
|
49
51
|
length = length.to_i
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
ensure_columns!("ConcernsOnRails::Models::Tokenizable", field)
|
|
54
|
+
validate_tokenizable_options!(type, length)
|
|
52
55
|
|
|
53
56
|
# Build a fresh hash so subclasses don't mutate the parent's config.
|
|
54
57
|
self.tokenizable_fields = tokenizable_fields.merge(field => { type: type, length: length })
|
|
@@ -69,8 +72,8 @@ module ConcernsOnRails
|
|
|
69
72
|
case config[:type]
|
|
70
73
|
when :urlsafe then SecureRandom.urlsafe_base64(length)[0, length]
|
|
71
74
|
when :hex then SecureRandom.hex((length + 1) / 2)[0, length]
|
|
72
|
-
when :alphanumeric then
|
|
73
|
-
when :numeric then
|
|
75
|
+
when :alphanumeric then ConcernsOnRails::Support::RandomValue.from_alphabet(ALPHANUMERIC_ALPHABET, length)
|
|
76
|
+
when :numeric then ConcernsOnRails::Support::RandomValue.from_alphabet(NUMERIC_ALPHABET, length)
|
|
74
77
|
end
|
|
75
78
|
end
|
|
76
79
|
end
|
|
@@ -100,12 +103,7 @@ module ConcernsOnRails
|
|
|
100
103
|
end
|
|
101
104
|
|
|
102
105
|
class_methods do
|
|
103
|
-
def validate_tokenizable_options!(
|
|
104
|
-
unless column_names.include?(field.to_s)
|
|
105
|
-
raise ArgumentError,
|
|
106
|
-
"ConcernsOnRails::Models::Tokenizable: tokenizable field '#{field}' does not exist in the database"
|
|
107
|
-
end
|
|
108
|
-
|
|
106
|
+
def validate_tokenizable_options!(type, length)
|
|
109
107
|
unless VALID_TYPES.include?(type)
|
|
110
108
|
raise ArgumentError,
|
|
111
109
|
"ConcernsOnRails::Models::Tokenizable: unknown type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
|
|
@@ -116,11 +114,7 @@ module ConcernsOnRails
|
|
|
116
114
|
raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: length must be a positive integer"
|
|
117
115
|
end
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
Array.new(length) { alphabet[SecureRandom.random_number(alphabet.size)] }.join
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
private :validate_tokenizable_options!, :random_string_from_alphabet
|
|
117
|
+
private :validate_tokenizable_options!
|
|
124
118
|
end
|
|
125
119
|
|
|
126
120
|
# Assigns the generated value only when blank, so callers can pass an explicit one.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Support
|
|
5
|
+
# Shared schema-validation helper mixed into a concern's ClassMethods.
|
|
6
|
+
# Runs in class context, so `column_names` / `table_name` resolve against
|
|
7
|
+
# the including model. Centralizes the column-existence check that every
|
|
8
|
+
# model concern used to re-implement, and keeps the error wording uniform.
|
|
9
|
+
#
|
|
10
|
+
# class_methods do
|
|
11
|
+
# include ConcernsOnRails::Support::ColumnGuard
|
|
12
|
+
#
|
|
13
|
+
# def activatable_by(field = :active)
|
|
14
|
+
# self.activatable_field = field.to_sym
|
|
15
|
+
# ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field)
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# The phrase "does not exist" is preserved so existing specs that match
|
|
20
|
+
# /does not exist/ keep passing.
|
|
21
|
+
module ColumnGuard
|
|
22
|
+
def ensure_columns!(concern, *fields)
|
|
23
|
+
fields.flatten.compact.each do |field|
|
|
24
|
+
next if column_names.include?(field.to_s)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"#{concern}: '#{field}' does not exist in the database (table: #{table_name})"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Support
|
|
5
|
+
# Shared random-value generation used by Hashable (:custom) and Tokenizable
|
|
6
|
+
# (:alphanumeric / :numeric). Samples `length` characters uniformly from
|
|
7
|
+
# `alphabet` using SecureRandom.
|
|
8
|
+
module RandomValue
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def from_alphabet(alphabet, length)
|
|
12
|
+
Array.new(length) { alphabet[SecureRandom.random_number(alphabet.size)] }.join
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -4,8 +4,13 @@ require "concerns_on_rails/version"
|
|
|
4
4
|
module ConcernsOnRails
|
|
5
5
|
module Models; end
|
|
6
6
|
module Controllers; end
|
|
7
|
+
module Support; end
|
|
7
8
|
end
|
|
8
9
|
|
|
10
|
+
# Shared internal helpers (must load before the concerns that use them)
|
|
11
|
+
require "concerns_on_rails/support/column_guard"
|
|
12
|
+
require "concerns_on_rails/support/random_value"
|
|
13
|
+
|
|
9
14
|
# Model concerns
|
|
10
15
|
require "concerns_on_rails/models/sluggable"
|
|
11
16
|
require "concerns_on_rails/models/sortable"
|
|
@@ -18,6 +23,7 @@ require "concerns_on_rails/models/normalizable"
|
|
|
18
23
|
require "concerns_on_rails/models/searchable"
|
|
19
24
|
require "concerns_on_rails/models/activatable"
|
|
20
25
|
require "concerns_on_rails/models/tokenizable"
|
|
26
|
+
require "concerns_on_rails/models/stateable"
|
|
21
27
|
|
|
22
28
|
# Controller concerns
|
|
23
29
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -25,6 +31,7 @@ require "concerns_on_rails/controllers/filterable"
|
|
|
25
31
|
require "concerns_on_rails/controllers/sortable"
|
|
26
32
|
require "concerns_on_rails/controllers/respondable"
|
|
27
33
|
require "concerns_on_rails/controllers/error_handleable"
|
|
34
|
+
require "concerns_on_rails/controllers/includable"
|
|
28
35
|
|
|
29
36
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
30
37
|
require "concerns_on_rails/legacy_aliases"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -72,6 +72,7 @@ files:
|
|
|
72
72
|
- lib/concerns_on_rails.rb
|
|
73
73
|
- lib/concerns_on_rails/controllers/error_handleable.rb
|
|
74
74
|
- lib/concerns_on_rails/controllers/filterable.rb
|
|
75
|
+
- lib/concerns_on_rails/controllers/includable.rb
|
|
75
76
|
- lib/concerns_on_rails/controllers/paginatable.rb
|
|
76
77
|
- lib/concerns_on_rails/controllers/respondable.rb
|
|
77
78
|
- lib/concerns_on_rails/controllers/sortable.rb
|
|
@@ -86,7 +87,10 @@ files:
|
|
|
86
87
|
- lib/concerns_on_rails/models/sluggable.rb
|
|
87
88
|
- lib/concerns_on_rails/models/soft_deletable.rb
|
|
88
89
|
- lib/concerns_on_rails/models/sortable.rb
|
|
90
|
+
- lib/concerns_on_rails/models/stateable.rb
|
|
89
91
|
- lib/concerns_on_rails/models/tokenizable.rb
|
|
92
|
+
- lib/concerns_on_rails/support/column_guard.rb
|
|
93
|
+
- lib/concerns_on_rails/support/random_value.rb
|
|
90
94
|
- lib/concerns_on_rails/version.rb
|
|
91
95
|
homepage: https://github.com/VSN2015/concerns_on_rails
|
|
92
96
|
licenses:
|