concerns_on_rails 1.8.2 → 1.10.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 +22 -0
- data/README.md +229 -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/addressable.rb +212 -0
- 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/address_data.rb +102 -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 +9 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 743380a3be200eda289e97edef227f8b900550ba08af52c4a06b995fc5c8297a
|
|
4
|
+
data.tar.gz: 7e8e8cb4bfd923819ec96178d75e68378829fb0f80b5a2e3cf3cd84e150fcc32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48e77b0bb95ee7419207289be7e1f74166f43aa4e31f4af6a6269ac2a84ccb90ac38e81a2a11a44cdafb7bd678d48153070c41d502b37f0a6baeabfd114f73f0
|
|
7
|
+
data.tar.gz: bb03d4bcfb6b26b7963f70cdaee8e591293fc8f96e34aa636aab84df6ce20647ae9df01e3d52554c8b6a9cb52cfce68c2da2c0f967ede2a3ec7960be737f4bd2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.10.0 (2026-06-03)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Models::Addressable**: Declarative postal-address normalization + format validation via a single `addressable_by` macro. Maps the canonical parts (`line1` / `line2` / `city` / `state` / `postal_code` / `country`) onto real columns — any subset works, missing columns are skipped, and required parts are schema-checked. Normalizes in `before_validation` (strip + squish, postal-code upcasing with canonical CA spacing, 2-letter country/state codes upcased) and validates required-part presence, ISO 3166-1 alpha-2 country codes, per-country postal formats (US/CA/GB/AU/DE/FR + a permissive fallback), and opt-in US/CA state codes. Offline and dependency-free; layer real deliverability checks via the opt-in `verify_with:` callable. Adds helpers `full_address`, `address_lines`, `address_present?`, `address_complete?`, and `address_attributes`.
|
|
7
|
+
|
|
8
|
+
### Internal
|
|
9
|
+
- Added `ConcernsOnRails::Support::AddressData` — ISO 3166-1 alpha-2 country codes, per-country postal-format patterns, US state / CA province sets, and the case-insensitive lookups (`valid_country?`, `postal_format_for`, `valid_state?`, `normalize_postal`) backing the Addressable concern.
|
|
10
|
+
|
|
11
|
+
## 1.9.0 (2026-05-25)
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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).
|
|
17
|
+
- **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).
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
21
|
+
### Internal
|
|
22
|
+
- 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.
|
|
23
|
+
- Extracted duplicated random-value generation (`Hashable` `:custom` branch + `Tokenizable`) into `ConcernsOnRails::Support::RandomValue`. No behavior change.
|
|
24
|
+
|
|
3
25
|
## 1.8.2 (2026-05-22)
|
|
4
26
|
|
|
5
27
|
### Internal
|
data/README.md
CHANGED
|
@@ -36,12 +36,15 @@ 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
|
|
40
|
+
- [Addressable](#-addressable) — postal address normalization + format validation
|
|
39
41
|
- **Controller concerns**
|
|
40
42
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
41
43
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
42
44
|
- [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
|
|
43
45
|
- [Respondable](#-respondable) — standardized JSON envelopes
|
|
44
46
|
- [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
|
|
47
|
+
- [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
|
|
45
48
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
46
49
|
- [Development](#-development)
|
|
47
50
|
- [Contributing](#-contributing)
|
|
@@ -51,7 +54,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
51
54
|
|
|
52
55
|
## ✨ Why this gem?
|
|
53
56
|
|
|
54
|
-
- **
|
|
57
|
+
- **Thirteen model concerns + six controller concerns**, all production-ready
|
|
55
58
|
- **One include, one macro** — no boilerplate, no glue code
|
|
56
59
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
57
60
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -64,7 +67,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
64
67
|
Add to your application's `Gemfile`:
|
|
65
68
|
|
|
66
69
|
```ruby
|
|
67
|
-
gem "concerns_on_rails", "~> 1.
|
|
70
|
+
gem "concerns_on_rails", "~> 1.9"
|
|
68
71
|
```
|
|
69
72
|
|
|
70
73
|
Or pull the latest from GitHub:
|
|
@@ -145,10 +148,23 @@ post.slug # => "hello-world" (regenerates on title change)
|
|
|
145
148
|
Post.friendly.find("hello-world")
|
|
146
149
|
```
|
|
147
150
|
|
|
151
|
+
**Options**
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
# Keep old slugs resolvable after a title change (needs a friendly_id_slugs migration)
|
|
155
|
+
sluggable_by :title, history: true
|
|
156
|
+
Post.friendly.find("old-slug") # still resolves to the renamed post
|
|
157
|
+
|
|
158
|
+
# Unique slug only within a scope column (same slug allowed in different accounts)
|
|
159
|
+
sluggable_by :title, scope: :account_id
|
|
160
|
+
```
|
|
161
|
+
|
|
148
162
|
**Notes**
|
|
149
163
|
- Schema must have a `slug` column (string).
|
|
164
|
+
- `history: true` requires a `friendly_id_slugs` table — generate with `rails generate friendly_id` or add a manual migration.
|
|
165
|
+
- `scope: :col` requires `col` to exist in the same table.
|
|
150
166
|
- Falls back to `to_s` if the configured source field doesn't respond.
|
|
151
|
-
- Uses friendly_id's `:slugged`
|
|
167
|
+
- Uses friendly_id's `:slugged` (+ optionally `:history`, `:scoped`) strategies under the hood.
|
|
152
168
|
|
|
153
169
|
---
|
|
154
170
|
|
|
@@ -201,11 +217,29 @@ article.unpublish!
|
|
|
201
217
|
|
|
202
218
|
Article.published # WHERE published_at <= NOW()
|
|
203
219
|
Article.unpublished # WHERE published_at IS NULL OR published_at > NOW()
|
|
220
|
+
Article.scheduled # WHERE published_at > NOW() (future-dated — not live yet)
|
|
221
|
+
Article.draft # WHERE published_at IS NULL (no date set at all)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Scheduling**
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
article.publish_at!(1.week.from_now) # sets published_at to a future time
|
|
228
|
+
article.scheduled? # => true (not live yet)
|
|
229
|
+
article.draft? # => false
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Opt-in default scope**
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
publishable_by :published_at, default_scope: true
|
|
236
|
+
# Article.all now returns only published records automatically
|
|
237
|
+
# Article.unscoped reaches everything
|
|
204
238
|
```
|
|
205
239
|
|
|
206
240
|
**Notes**
|
|
207
|
-
- "Published" means `published_at` is set **and** in the past — so
|
|
208
|
-
- No `default_scope` is added; chain `.published` explicitly.
|
|
241
|
+
- "Published" means `published_at` is set **and** in the past — so future-dated posts stay unpublished until their time arrives.
|
|
242
|
+
- No `default_scope` is added by default; chain `.published` explicitly (or opt in with `default_scope: true`).
|
|
209
243
|
|
|
210
244
|
---
|
|
211
245
|
|
|
@@ -232,11 +266,14 @@ user.really_delete! # bypasses callbacks, hard deletes from DB
|
|
|
232
266
|
**Scopes**
|
|
233
267
|
|
|
234
268
|
```ruby
|
|
235
|
-
User.active
|
|
236
|
-
User.without_deleted
|
|
237
|
-
User.soft_deleted
|
|
238
|
-
User.
|
|
239
|
-
User.
|
|
269
|
+
User.active # alias of .without_deleted — non-deleted records
|
|
270
|
+
User.without_deleted # same
|
|
271
|
+
User.soft_deleted # only deleted records (timestamp set)
|
|
272
|
+
User.only_deleted # alias for .soft_deleted
|
|
273
|
+
User.with_deleted # all records — deleted + non-deleted
|
|
274
|
+
User.deleted_within(7.days) # soft-deleted within the last 7 days
|
|
275
|
+
User.all # default scope: non-deleted only
|
|
276
|
+
User.unscoped # everything (deleted + non-deleted)
|
|
240
277
|
```
|
|
241
278
|
|
|
242
279
|
**Bulk operations**
|
|
@@ -244,6 +281,7 @@ User.unscoped # everything (deleted + non-deleted)
|
|
|
244
281
|
```ruby
|
|
245
282
|
User.destroy_all # soft-deletes all matching records
|
|
246
283
|
User.really_destroy_all # hard-deletes all matching records
|
|
284
|
+
User.restore_all # restores all soft-deleted records
|
|
247
285
|
```
|
|
248
286
|
|
|
249
287
|
**Lifecycle hooks** — override these methods on the model:
|
|
@@ -449,11 +487,25 @@ Article.search("") # no-op — returns the full relation
|
|
|
449
487
|
Article.search("foo").where(state: 1) # chainable like any scope
|
|
450
488
|
```
|
|
451
489
|
|
|
490
|
+
**Options**
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
# mode: :any (default) — any term in any field matches (OR)
|
|
494
|
+
# mode: :all — every whitespace-separated term must match somewhere (AND per term, OR across fields)
|
|
495
|
+
searchable_by :title, :body, mode: :all
|
|
496
|
+
Article.search("ruby framework") # title OR body must contain "ruby" AND "framework"
|
|
497
|
+
|
|
498
|
+
# match: :contains (default) — %term% (substring)
|
|
499
|
+
# match: :prefix — term% (starts with)
|
|
500
|
+
# match: :exact — term (full match)
|
|
501
|
+
searchable_by :sku, match: :prefix
|
|
502
|
+
```
|
|
503
|
+
|
|
452
504
|
**Notes**
|
|
453
505
|
- Uses Arel's `matches`, which emits `ILIKE` on Postgres (case-insensitive) and `LIKE` elsewhere.
|
|
454
506
|
- 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
|
-
-
|
|
507
|
+
- Blank or nil queries return the relation unchanged, so it's safe to drop into a controller pipeline.
|
|
508
|
+
- Reach for `pg_search` / Elasticsearch when you need ranking, stemming, or full-text indexes.
|
|
457
509
|
|
|
458
510
|
---
|
|
459
511
|
|
|
@@ -526,6 +578,130 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
|
|
|
526
578
|
|
|
527
579
|
---
|
|
528
580
|
|
|
581
|
+
## 🔄 Stateable
|
|
582
|
+
|
|
583
|
+
Lightweight string-backed state machine — the 80% of AASM without the dependency.
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
class Article < ApplicationRecord
|
|
587
|
+
include ConcernsOnRails::Stateable
|
|
588
|
+
|
|
589
|
+
stateable_by :status,
|
|
590
|
+
states: %i[draft pending published archived],
|
|
591
|
+
default: :draft,
|
|
592
|
+
transitions: {
|
|
593
|
+
publish: { from: %i[draft pending], to: :published },
|
|
594
|
+
archive: { to: :archived } # no :from → allowed from any state
|
|
595
|
+
}
|
|
596
|
+
end
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**Generated API**
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
article = Article.new # status defaults to "draft"
|
|
603
|
+
article.draft? # => true (predicate per state)
|
|
604
|
+
article.published? # => false
|
|
605
|
+
|
|
606
|
+
Article.draft # scope: WHERE status = 'draft'
|
|
607
|
+
Article.published # scope: WHERE status = 'published'
|
|
608
|
+
|
|
609
|
+
article.published! # direct setter — updates regardless of current state
|
|
610
|
+
article.publish! # guarded transition — raises InvalidTransition if not allowed
|
|
611
|
+
article.may_publish? # => true (guard check without raising)
|
|
612
|
+
|
|
613
|
+
article.transition_to!(:archived) # generic move to any declared state
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
**Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
|
|
617
|
+
|
|
618
|
+
```ruby
|
|
619
|
+
stateable_by :state, states: %i[open closed], prefix: true
|
|
620
|
+
# generates: state_open?, state_closed?, state_open!, state_closed!, State.state_open, …
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**Validation**
|
|
624
|
+
- Raises `ArgumentError` if the column, states, default, or transition config is invalid.
|
|
625
|
+
- Raises `Stateable::InvalidTransition` at runtime for disallowed guarded transitions.
|
|
626
|
+
|
|
627
|
+
**Notes**
|
|
628
|
+
- String-column backed (not integer-backed like Rails enum) — values are stored as-is.
|
|
629
|
+
- States like `active` / `expired` overlap with `Activatable`/`Expirable` scopes — use `prefix:` or `suffix:` to disambiguate.
|
|
630
|
+
- No persistence of transition history; combine with `Publishable` / `Schedulable` for time-based state tracking.
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
## 🏠 Addressable
|
|
635
|
+
|
|
636
|
+
Normalize and format-validate a postal address spread across several columns — one macro for whitespace cleanup, postal-code and ISO country-code checks, required-part presence, and a `full_address` helper. No external geocoding service required.
|
|
637
|
+
|
|
638
|
+
```ruby
|
|
639
|
+
class Location < ApplicationRecord
|
|
640
|
+
include ConcernsOnRails::Addressable
|
|
641
|
+
|
|
642
|
+
addressable_by # standard columns: line1, line2, city, state, postal_code, country
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
loc = Location.create(line1: " 1 Infinite Loop ", city: "Cupertino",
|
|
646
|
+
state: "ca", postal_code: "95014", country: "us")
|
|
647
|
+
loc.line1 # => "1 Infinite Loop" (stripped + squished)
|
|
648
|
+
loc.state # => "CA" (2-letter code upcased)
|
|
649
|
+
loc.country # => "US"
|
|
650
|
+
loc.full_address # => "1 Infinite Loop, Cupertino, CA, 95014, US"
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
Map onto your own column names and tune behavior:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
class Place < ApplicationRecord
|
|
657
|
+
include ConcernsOnRails::Addressable
|
|
658
|
+
|
|
659
|
+
addressable_by line1: :street, postal_code: :zip, country: :country_code,
|
|
660
|
+
required: %i[line1 city postal_code country],
|
|
661
|
+
default_country: "GB", # country used when the record has none
|
|
662
|
+
validate_state: true, # opt-in US/CA state-code check
|
|
663
|
+
verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
|
|
664
|
+
end
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
**Options**
|
|
668
|
+
|
|
669
|
+
| Option | Default | Purpose |
|
|
670
|
+
|-------------------|--------------------------------------|---------------------------------------------------------------------|
|
|
671
|
+
| `line1:` … `country:` | same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
|
|
672
|
+
| `required:` | `%i[line1 city postal_code country]` | Parts (by canonical name) that must be present. Each must map to an existing column. |
|
|
673
|
+
| `default_country:`| `"US"` | Country used to pick the postal-code format when the record has no recognized country. |
|
|
674
|
+
| `validate_state:` | `false` | When `true`, validates the state against US / CA code sets. |
|
|
675
|
+
| `verify_with:` | `nil` | A callable for real-world verification (see below). |
|
|
676
|
+
|
|
677
|
+
**What it normalizes** (in `before_validation`)
|
|
678
|
+
- Text parts: `strip` + `squish`.
|
|
679
|
+
- `postal_code`: squish + upcase, with canonical spacing for CA (`A1A1A1` → `A1A 1A1`).
|
|
680
|
+
- `country` / `state`: upcased when they look like a 2-letter code (full names left alone).
|
|
681
|
+
|
|
682
|
+
**What it validates**
|
|
683
|
+
- Presence of every `required:` part.
|
|
684
|
+
- `country`: a 2-letter value must be a real ISO 3166-1 alpha-2 code.
|
|
685
|
+
- `postal_code`: matched against a per-country pattern (US, CA, GB, AU, DE, FR) with a permissive fallback for everything else.
|
|
686
|
+
- `state`: only when `validate_state: true` and the country is US/CA.
|
|
687
|
+
|
|
688
|
+
**External verification (`verify_with:`)** — runs **only after** structural validation passes, so you never spend an API call on an obviously-broken address. The callable receives the record and may either add to `record.errors` itself, or return:
|
|
689
|
+
|
|
690
|
+
| Return value | Effect |
|
|
691
|
+
|-------------------|-------------------------------------------------|
|
|
692
|
+
| `true` / `nil` | success |
|
|
693
|
+
| `false` | adds a generic `:base` error |
|
|
694
|
+
| `String` | added as a `:base` error |
|
|
695
|
+
| `Array` | each element added as a `:base` error |
|
|
696
|
+
|
|
697
|
+
**Notes**
|
|
698
|
+
- Scope is **format/structure only** — it checks shape, not real-world deliverability. Plug a USPS/Google/Smarty client into `verify_with:` for that.
|
|
699
|
+
- Error messages are plain English strings — no host-app i18n setup required.
|
|
700
|
+
- Partial schemas just work: a model without a `line2` (or any other) column simply omits that part.
|
|
701
|
+
- Pairs with [Normalizable](#-normalizable) when you also have non-address fields to clean up.
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
529
705
|
# 🎮 Controller Concerns
|
|
530
706
|
|
|
531
707
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -709,6 +885,46 @@ end
|
|
|
709
885
|
|
|
710
886
|
---
|
|
711
887
|
|
|
888
|
+
## 🔗 Includable
|
|
889
|
+
|
|
890
|
+
Whitelisted association sideloading + sparse fieldsets for JSON APIs — zero arbitrary `.includes` from user input.
|
|
891
|
+
|
|
892
|
+
```ruby
|
|
893
|
+
class ArticlesController < ApplicationController
|
|
894
|
+
include ConcernsOnRails::Controllers::Includable
|
|
895
|
+
|
|
896
|
+
includable :author, :comments,
|
|
897
|
+
fields: { articles: %i[id title published_at], authors: %i[id name] }
|
|
898
|
+
|
|
899
|
+
def index
|
|
900
|
+
render json: with_includes(Article.all),
|
|
901
|
+
include: requested_includes,
|
|
902
|
+
fields: requested_fields
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
**URL params**
|
|
908
|
+
|
|
909
|
+
```
|
|
910
|
+
GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=id,name
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
**API**
|
|
914
|
+
|
|
915
|
+
| Method | What it does |
|
|
916
|
+
|----------------------|--------------------------------------------------------------------------------------------|
|
|
917
|
+
| `with_includes(rel)` | Parses `params[:include]`, intersects with the allow-list, calls `relation.includes(...)` |
|
|
918
|
+
| `requested_includes` | Returns the sanitized `[:author, :comments]` array (pass to `render json:, include:`) |
|
|
919
|
+
| `requested_fields` | Returns `{ articles: [:id, :title] }` sanitized map (pass to your serializer) |
|
|
920
|
+
|
|
921
|
+
**Notes**
|
|
922
|
+
- Non-whitelisted associations are **silently dropped** — no error, no arbitrary eager-loading.
|
|
923
|
+
- Non-whitelisted tables in `params[:fields]` are dropped; non-whitelisted columns within an allowed table are dropped.
|
|
924
|
+
- Pass `requested_fields` to your serializer (e.g. AMS / Blueprinter) — `Includable` itself does not alter the JSON output, only the query.
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
712
928
|
## 🗂️ Module paths & namespacing
|
|
713
929
|
|
|
714
930
|
Every concern is available under two paths:
|
|
@@ -740,7 +956,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
740
956
|
bundle install # install dev dependencies
|
|
741
957
|
bundle exec rspec # run the test suite
|
|
742
958
|
gem build concerns_on_rails.gemspec # build the gem
|
|
743
|
-
gem install ./concerns_on_rails-1.
|
|
959
|
+
gem install ./concerns_on_rails-1.9.0.gem # install locally
|
|
744
960
|
```
|
|
745
961
|
|
|
746
962
|
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]) }
|