concerns_on_rails 1.7.0 → 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 +35 -0
- data/README.md +198 -13
- data/lib/concerns_on_rails/controllers/includable.rb +85 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -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 +139 -0
- 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 +8 -0
- metadata +7 -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,40 @@
|
|
|
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
|
+
|
|
17
|
+
## 1.8.2 (2026-05-22)
|
|
18
|
+
|
|
19
|
+
### Internal
|
|
20
|
+
- Regenerated `Gemfile.lock` so the pinned `concerns_on_rails` version matches the gemspec. No behavior change.
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
- The `v1.8.1` tag was pushed but failed CI (`bundle install --deployment` rejected the stale `Gemfile.lock`); `1.8.2` is the first usable release of the Tokenizable concern.
|
|
24
|
+
|
|
25
|
+
## 1.8.1 (2026-05-22)
|
|
26
|
+
|
|
27
|
+
### Internal
|
|
28
|
+
- Refactored `Models::Tokenizable` `class_methods` blocks to satisfy `Metrics/BlockLength`. No behavior change.
|
|
29
|
+
|
|
30
|
+
### Notes
|
|
31
|
+
- The `v1.8.0` tag was pushed but failed RuboCop; `1.8.1` is the first usable release of the Tokenizable concern.
|
|
32
|
+
|
|
33
|
+
## 1.8.0 (2026-05-22)
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **Models::Tokenizable**: Security-token generation for API keys, invite codes, share links, password-reset tokens. Each `tokenizable_by` call adds an independently-configured field (one model can hold many tokens). Defaults to 32-char URL-safe values; also supports `:hex`, `:alphanumeric`, and `:numeric` types with a configurable `length:`. Auto-generates on create with best-effort uniqueness retry, and provides `regenerate_<field>!`, `revoke_<field>!`, `<field>?`, and a timing-safe `.authenticate_by_<field>` class method.
|
|
37
|
+
|
|
3
38
|
## 1.7.0 (2026-05-21)
|
|
4
39
|
|
|
5
40
|
### Added
|
data/README.md
CHANGED
|
@@ -35,12 +35,15 @@ Article.published.without_deleted.find("hello-world")
|
|
|
35
35
|
- [Normalizable](#-normalizable) — attribute normalization (`:email`, `:phone`, …)
|
|
36
36
|
- [Searchable](#-searchable) — LIKE/ILIKE search across configured columns
|
|
37
37
|
- [Activatable](#-activatable) — boolean active/inactive toggle
|
|
38
|
+
- [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
|
|
39
|
+
- [Stateable](#-stateable) — lightweight string-backed state machine
|
|
38
40
|
- **Controller concerns**
|
|
39
41
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
40
42
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
41
43
|
- [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
|
|
42
44
|
- [Respondable](#-respondable) — standardized JSON envelopes
|
|
43
45
|
- [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
|
|
46
|
+
- [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
|
|
44
47
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
45
48
|
- [Development](#-development)
|
|
46
49
|
- [Contributing](#-contributing)
|
|
@@ -50,7 +53,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
50
53
|
|
|
51
54
|
## ✨ Why this gem?
|
|
52
55
|
|
|
53
|
-
- **
|
|
56
|
+
- **Twelve model concerns + six controller concerns**, all production-ready
|
|
54
57
|
- **One include, one macro** — no boilerplate, no glue code
|
|
55
58
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
56
59
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -63,7 +66,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
63
66
|
Add to your application's `Gemfile`:
|
|
64
67
|
|
|
65
68
|
```ruby
|
|
66
|
-
gem "concerns_on_rails", "~> 1.
|
|
69
|
+
gem "concerns_on_rails", "~> 1.9"
|
|
67
70
|
```
|
|
68
71
|
|
|
69
72
|
Or pull the latest from GitHub:
|
|
@@ -144,10 +147,23 @@ post.slug # => "hello-world" (regenerates on title change)
|
|
|
144
147
|
Post.friendly.find("hello-world")
|
|
145
148
|
```
|
|
146
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
|
+
|
|
147
161
|
**Notes**
|
|
148
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.
|
|
149
165
|
- Falls back to `to_s` if the configured source field doesn't respond.
|
|
150
|
-
- Uses friendly_id's `:slugged`
|
|
166
|
+
- Uses friendly_id's `:slugged` (+ optionally `:history`, `:scoped`) strategies under the hood.
|
|
151
167
|
|
|
152
168
|
---
|
|
153
169
|
|
|
@@ -200,11 +216,29 @@ article.unpublish!
|
|
|
200
216
|
|
|
201
217
|
Article.published # WHERE published_at <= NOW()
|
|
202
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
|
|
203
237
|
```
|
|
204
238
|
|
|
205
239
|
**Notes**
|
|
206
|
-
- "Published" means `published_at` is set **and** in the past — so
|
|
207
|
-
- 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`).
|
|
208
242
|
|
|
209
243
|
---
|
|
210
244
|
|
|
@@ -231,11 +265,14 @@ user.really_delete! # bypasses callbacks, hard deletes from DB
|
|
|
231
265
|
**Scopes**
|
|
232
266
|
|
|
233
267
|
```ruby
|
|
234
|
-
User.active
|
|
235
|
-
User.without_deleted
|
|
236
|
-
User.soft_deleted
|
|
237
|
-
User.
|
|
238
|
-
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)
|
|
239
276
|
```
|
|
240
277
|
|
|
241
278
|
**Bulk operations**
|
|
@@ -243,6 +280,7 @@ User.unscoped # everything (deleted + non-deleted)
|
|
|
243
280
|
```ruby
|
|
244
281
|
User.destroy_all # soft-deletes all matching records
|
|
245
282
|
User.really_destroy_all # hard-deletes all matching records
|
|
283
|
+
User.restore_all # restores all soft-deleted records
|
|
246
284
|
```
|
|
247
285
|
|
|
248
286
|
**Lifecycle hooks** — override these methods on the model:
|
|
@@ -448,11 +486,25 @@ Article.search("") # no-op — returns the full relation
|
|
|
448
486
|
Article.search("foo").where(state: 1) # chainable like any scope
|
|
449
487
|
```
|
|
450
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
|
+
|
|
451
503
|
**Notes**
|
|
452
504
|
- Uses Arel's `matches`, which emits `ILIKE` on Postgres (case-insensitive) and `LIKE` elsewhere.
|
|
453
505
|
- The query is escaped before interpolation — `%`, `_`, and `\` from user input are treated as literals, not wildcards.
|
|
454
|
-
- Blank or nil queries return the relation unchanged so it's safe to drop into a controller pipeline.
|
|
455
|
-
-
|
|
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.
|
|
456
508
|
|
|
457
509
|
---
|
|
458
510
|
|
|
@@ -485,6 +537,99 @@ Subscription.inactive # WHERE active = FALSE OR active IS NULL
|
|
|
485
537
|
|
|
486
538
|
---
|
|
487
539
|
|
|
540
|
+
## 🔑 Tokenizable
|
|
541
|
+
|
|
542
|
+
Generate and manage security tokens — API keys, invite codes, share links, password-reset tokens. One model can declare any number of independently-configured token fields.
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
class User < ApplicationRecord
|
|
546
|
+
include ConcernsOnRails::Tokenizable
|
|
547
|
+
|
|
548
|
+
tokenizable_by :api_token # 32-char URL-safe
|
|
549
|
+
tokenizable_by :reset_password_token, length: 24
|
|
550
|
+
tokenizable_by :invite_code, type: :alphanumeric, length: 8
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
user = User.create! # all three tokens auto-generated
|
|
554
|
+
user.api_token # => "k3Jf...g2" (32 URL-safe chars)
|
|
555
|
+
user.api_token? # => true
|
|
556
|
+
|
|
557
|
+
user.regenerate_api_token! # rotates and persists
|
|
558
|
+
user.revoke_api_token! # nils the column
|
|
559
|
+
|
|
560
|
+
User.find_by_api_token(token) # Rails default
|
|
561
|
+
User.authenticate_by_api_token(token) # timing-safe; returns user or nil
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
**Options**
|
|
565
|
+
|
|
566
|
+
| Option | Default | Notes |
|
|
567
|
+
| -------- | ----------- | ------------------------------------------------------------- |
|
|
568
|
+
| `type:` | `:urlsafe` | One of `:urlsafe`, `:hex`, `:alphanumeric`, `:numeric` |
|
|
569
|
+
| `length:`| `32` | Character length of the generated token |
|
|
570
|
+
|
|
571
|
+
**Notes**
|
|
572
|
+
- URL-safe by default (`A–Z`, `a–z`, `0–9`, `-`, `_`) — drop straight into URLs and headers.
|
|
573
|
+
- Caller-supplied values are respected: `User.create!(api_token: "preset")` won't be overwritten.
|
|
574
|
+
- Generation does a best-effort uniqueness check before insert and retries up to 10 times. Pair with a `unique` DB index for real safety, especially for short alphanumeric/numeric codes.
|
|
575
|
+
- `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing.
|
|
576
|
+
- Distinct from `Hashable`: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
|
|
577
|
+
|
|
578
|
+
---
|
|
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
|
+
|
|
488
633
|
# 🎮 Controller Concerns
|
|
489
634
|
|
|
490
635
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -668,6 +813,46 @@ end
|
|
|
668
813
|
|
|
669
814
|
---
|
|
670
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
|
+
|
|
671
856
|
## 🗂️ Module paths & namespacing
|
|
672
857
|
|
|
673
858
|
Every concern is available under two paths:
|
|
@@ -699,7 +884,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
699
884
|
bundle install # install dev dependencies
|
|
700
885
|
bundle exec rspec # run the test suite
|
|
701
886
|
gem build concerns_on_rails.gemspec # build the gem
|
|
702
|
-
gem install ./concerns_on_rails-1.
|
|
887
|
+
gem install ./concerns_on_rails-1.9.0.gem # install locally
|
|
703
888
|
```
|
|
704
889
|
|
|
705
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
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "active_support/security_utils"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module ConcernsOnRails
|
|
6
|
+
module Models
|
|
7
|
+
# Generates and manages security tokens (API keys, invite codes, share links).
|
|
8
|
+
#
|
|
9
|
+
# class User < ApplicationRecord
|
|
10
|
+
# include ConcernsOnRails::Tokenizable
|
|
11
|
+
#
|
|
12
|
+
# tokenizable_by :api_token # 32-char URL-safe
|
|
13
|
+
# tokenizable_by :reset_password_token, length: 24
|
|
14
|
+
# tokenizable_by :invite_code, type: :alphanumeric, length: 8
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# user = User.create! # tokens auto-generated on create
|
|
18
|
+
# user.regenerate_api_token! # new value, persisted
|
|
19
|
+
# user.revoke_api_token! # sets the column to nil
|
|
20
|
+
# user.api_token? # true if present
|
|
21
|
+
#
|
|
22
|
+
# User.find_by_api_token(token) # Rails default
|
|
23
|
+
# User.authenticate_by_api_token(token) # timing-safe lookup, returns record or nil
|
|
24
|
+
#
|
|
25
|
+
# Unlike Hashable, one model can declare multiple token fields, generation is
|
|
26
|
+
# URL-safe by default, and `assign_tokenizable_value` retries on uniqueness
|
|
27
|
+
# collisions before insert (best-effort; pair with a unique DB index).
|
|
28
|
+
module Tokenizable
|
|
29
|
+
extend ActiveSupport::Concern
|
|
30
|
+
|
|
31
|
+
VALID_TYPES = %i[urlsafe hex alphanumeric numeric].freeze
|
|
32
|
+
ALPHANUMERIC_ALPHABET = (("A".."Z").to_a + ("a".."z").to_a + ("0".."9").to_a).freeze
|
|
33
|
+
NUMERIC_ALPHABET = ("0".."9").to_a.freeze
|
|
34
|
+
MAX_GENERATION_ATTEMPTS = 10
|
|
35
|
+
|
|
36
|
+
included do
|
|
37
|
+
class_attribute :tokenizable_fields, instance_accessor: false, default: {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
42
|
+
|
|
43
|
+
# Configure a tokenizable field.
|
|
44
|
+
#
|
|
45
|
+
# Options:
|
|
46
|
+
# type: one of :urlsafe (default), :hex, :alphanumeric, :numeric
|
|
47
|
+
# length: character length of the generated token (default 32)
|
|
48
|
+
def tokenizable_by(field, type: :urlsafe, length: 32)
|
|
49
|
+
field = field.to_sym
|
|
50
|
+
type = type.to_sym
|
|
51
|
+
length = length.to_i
|
|
52
|
+
|
|
53
|
+
ensure_columns!("ConcernsOnRails::Models::Tokenizable", field)
|
|
54
|
+
validate_tokenizable_options!(type, length)
|
|
55
|
+
|
|
56
|
+
# Build a fresh hash so subclasses don't mutate the parent's config.
|
|
57
|
+
self.tokenizable_fields = tokenizable_fields.merge(field => { type: type, length: length })
|
|
58
|
+
|
|
59
|
+
before_create -> { assign_tokenizable_value(field) }
|
|
60
|
+
|
|
61
|
+
define_tokenizable_methods(field)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Generate a new random value for the given field using its configured type/length.
|
|
65
|
+
def generate_tokenizable_value(field)
|
|
66
|
+
config = tokenizable_fields.fetch(field) do
|
|
67
|
+
raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: '#{field}' is not a tokenizable field"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
length = config[:length]
|
|
71
|
+
|
|
72
|
+
case config[:type]
|
|
73
|
+
when :urlsafe then SecureRandom.urlsafe_base64(length)[0, length]
|
|
74
|
+
when :hex then SecureRandom.hex((length + 1) / 2)[0, length]
|
|
75
|
+
when :alphanumeric then ConcernsOnRails::Support::RandomValue.from_alphabet(ALPHANUMERIC_ALPHABET, length)
|
|
76
|
+
when :numeric then ConcernsOnRails::Support::RandomValue.from_alphabet(NUMERIC_ALPHABET, length)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class_methods do
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def define_tokenizable_methods(field)
|
|
85
|
+
define_method("regenerate_#{field}!") { update!(field => self.class.generate_tokenizable_value(field)) }
|
|
86
|
+
define_method("revoke_#{field}!") { update!(field => nil) }
|
|
87
|
+
define_method("#{field}?") { self[field].present? }
|
|
88
|
+
define_singleton_method("authenticate_by_#{field}") { |value| timing_safe_find(field, value) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def timing_safe_find(field, value)
|
|
92
|
+
return nil if value.blank?
|
|
93
|
+
|
|
94
|
+
candidate = find_by(field => value)
|
|
95
|
+
return nil unless candidate
|
|
96
|
+
|
|
97
|
+
stored = candidate[field].to_s
|
|
98
|
+
given = value.to_s
|
|
99
|
+
return nil unless stored.bytesize == given.bytesize
|
|
100
|
+
|
|
101
|
+
ActiveSupport::SecurityUtils.secure_compare(stored, given) ? candidate : nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class_methods do
|
|
106
|
+
def validate_tokenizable_options!(type, length)
|
|
107
|
+
unless VALID_TYPES.include?(type)
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"ConcernsOnRails::Models::Tokenizable: unknown type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return if length.positive?
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: length must be a positive integer"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private :validate_tokenizable_options!
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Assigns the generated value only when blank, so callers can pass an explicit one.
|
|
121
|
+
# Retries up to MAX_GENERATION_ATTEMPTS times if the in-Ruby uniqueness check hits a
|
|
122
|
+
# collision — useful for short codes; a unique DB index is still the real guarantee.
|
|
123
|
+
def assign_tokenizable_value(field)
|
|
124
|
+
return if self[field].present?
|
|
125
|
+
|
|
126
|
+
MAX_GENERATION_ATTEMPTS.times do
|
|
127
|
+
candidate = self.class.generate_tokenizable_value(field)
|
|
128
|
+
unless self.class.unscoped.exists?(field => candidate)
|
|
129
|
+
self[field] = candidate
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
raise "ConcernsOnRails::Models::Tokenizable: could not generate a unique value for '#{field}' " \
|
|
135
|
+
"after #{MAX_GENERATION_ATTEMPTS} attempts — consider a longer length or a larger alphabet"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -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"
|
|
@@ -17,6 +22,8 @@ require "concerns_on_rails/models/expirable"
|
|
|
17
22
|
require "concerns_on_rails/models/normalizable"
|
|
18
23
|
require "concerns_on_rails/models/searchable"
|
|
19
24
|
require "concerns_on_rails/models/activatable"
|
|
25
|
+
require "concerns_on_rails/models/tokenizable"
|
|
26
|
+
require "concerns_on_rails/models/stateable"
|
|
20
27
|
|
|
21
28
|
# Controller concerns
|
|
22
29
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -24,6 +31,7 @@ require "concerns_on_rails/controllers/filterable"
|
|
|
24
31
|
require "concerns_on_rails/controllers/sortable"
|
|
25
32
|
require "concerns_on_rails/controllers/respondable"
|
|
26
33
|
require "concerns_on_rails/controllers/error_handleable"
|
|
34
|
+
require "concerns_on_rails/controllers/includable"
|
|
27
35
|
|
|
28
36
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
29
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,6 +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
|
|
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
|
|
89
94
|
- lib/concerns_on_rails/version.rb
|
|
90
95
|
homepage: https://github.com/VSN2015/concerns_on_rails
|
|
91
96
|
licenses:
|