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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5126c79ca466ff1a85a20eb0d4a29f7a2d471bc4186e7b4762481bfad2084f42
4
- data.tar.gz: '086922bf94b7efa7ef99699e4d96ab4e24ae2e731c2308bd1b0a8471e1f065a2'
3
+ metadata.gz: 743380a3be200eda289e97edef227f8b900550ba08af52c4a06b995fc5c8297a
4
+ data.tar.gz: 7e8e8cb4bfd923819ec96178d75e68378829fb0f80b5a2e3cf3cd84e150fcc32
5
5
  SHA512:
6
- metadata.gz: 6b3e968d8cf97c9d11b14ff1d8374a64731ed3416d114ca0acf525a64f65843a35f0261da219d9f725e539eba7ac6460ba331a3aaf2b701b88627d841ac70a2a
7
- data.tar.gz: 61f80dadd2112d36c1c1ceaf88fe01fb5dcf68f22911e1535e68414ecdbaca07ca363365c7ec3032c2698aec1cb0929d054036d2b8ebf73d1c347093976f3bb4
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
- - **Eleven model concerns + five controller concerns**, all production-ready
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.8"
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` strategy under the hood.
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 scheduled posts (future `published_at`) stay unpublished until their time arrives.
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 # alias of .without_deleted — non-deleted records
236
- User.without_deleted # same
237
- User.soft_deleted # only deleted records
238
- User.all # default scope: non-deleted only
239
- User.unscoped # everything (deleted + non-deleted)
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
- - Single-term substring match by design; reach for `pg_search` / Elasticsearch when you need ranking, stemming, or multi-term queries.
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.7.0.gem # install locally
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
@@ -13,4 +13,6 @@ module ConcernsOnRails
13
13
  Searchable = Models::Searchable
14
14
  Activatable = Models::Activatable
15
15
  Tokenizable = Models::Tokenizable
16
+ Stateable = Models::Stateable
17
+ Addressable = Models::Addressable
16
18
  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]) }