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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7bdaf4512b253a9c3b7307ace69f16d616f180d38ce3a3f5292099f0c97dedb5
4
- data.tar.gz: de2843d32ebd1cea0e3c9fce3e8f5d6d18adaa6eb6e92ce4056af3bb2ab4d368
3
+ metadata.gz: d01db047d91e470c1c2bb03f6fbe95be1eea7592199d9a548b6ac5dbacc84452
4
+ data.tar.gz: f47e6149addbee52badc7ae4d56fbc66c636adc75d5a32f7742a57d18eda1cd8
5
5
  SHA512:
6
- metadata.gz: 315ff80a677ef032630d0b81f8c0b5c3b88d132a288ad075ed00732e2d676ccef954725c8966231af793ee89dc2485cb48e6b0c1de03ea93d47c7f283545815d
7
- data.tar.gz: 13c59b78fc2b9dbd8dfca9877550a3a7fcd778fca493dd1b97c9151f9c4d111f94aa8eb3d8692b9b7e0299ccd52bf270944dee2a6987aae3c88917532cce2404
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
- - **Ten model concerns + five controller concerns**, all production-ready
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.7"
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` strategy under the hood.
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 scheduled posts (future `published_at`) stay unpublished until their time arrives.
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 # alias of .without_deleted — non-deleted records
235
- User.without_deleted # same
236
- User.soft_deleted # only deleted records
237
- User.all # default scope: non-deleted only
238
- User.unscoped # everything (deleted + non-deleted)
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
- - Single-term substring match by design; reach for `pg_search` / Elasticsearch when you need ranking, stemming, or multi-term queries.
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.7.0.gem # install locally
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
@@ -12,4 +12,6 @@ module ConcernsOnRails
12
12
  Normalizable = Models::Normalizable
13
13
  Searchable = Models::Searchable
14
14
  Activatable = Models::Activatable
15
+ Tokenizable = Models::Tokenizable
16
+ Stateable = Models::Stateable
15
17
  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 Array.new(hashable_length) { hashable_alphabet[SecureRandom.random_number(hashable_alphabet.size)] }.join
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
- fields.each { |field| validate_normalizable_field!(field) }
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
- where(arel_table[publishable_field].eq(nil).or(arel_table[publishable_field].gt(Time.zone.now)))
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
- def publishable_by(field = nil)
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
- return if column_names.include?(publishable_field.to_s)
34
+ private
22
35
 
23
- raise ArgumentError,
24
- "ConcernsOnRails::Models::Publishable: publishable_field '#{publishable_field}' does not exist in the database"
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
- [schedulable_starts_at_field, schedulable_ends_at_field].compact.each do |field|
56
- next if column_names.include?(field.to_s)
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
- # Uses Arel's `matches`, which emits ILIKE on Postgres and LIKE elsewhere —
18
- # so case-insensitivity comes for free on PG. The query is escaped before
19
- # interpolation, so `%` / `_` / `\` from user input are treated as literals.
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
- def searchable_by(*fields)
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.each do |field|
35
- unless column_names.include?(field.to_s)
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
- escaped = query.to_s.gsub(LIKE_SPECIAL) { |c| "#{LIKE_ESCAPE}#{c}" }
50
- pattern = "%#{escaped}%"
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
- predicates = searchable_fields.map { |field| arel_table[field].matches(pattern, LIKE_ESCAPE) }
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
- # Define sluggable field
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
- def sluggable_by(field)
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
- validate_sluggable_field!
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
- # Validate sluggable_field exists in database
44
- def validate_sluggable_field!
45
- return if column_names.include?(sluggable_field.to_s)
46
-
47
- raise ArgumentError, "ConcernsOnRails::Models::Sluggable: sluggable_field '#{sluggable_field}' does not exist in the database"
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
- unless column_names.include?(sortable_field.to_s)
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
- validate_sortable_field!
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
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.7.0".freeze
2
+ VERSION = "1.9.0".freeze
3
3
  end
@@ -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.7.0
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-21 00:00:00.000000000 Z
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: