concerns_on_rails 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5126c79ca466ff1a85a20eb0d4a29f7a2d471bc4186e7b4762481bfad2084f42
4
- data.tar.gz: '086922bf94b7efa7ef99699e4d96ab4e24ae2e731c2308bd1b0a8471e1f065a2'
3
+ metadata.gz: d01db047d91e470c1c2bb03f6fbe95be1eea7592199d9a548b6ac5dbacc84452
4
+ data.tar.gz: f47e6149addbee52badc7ae4d56fbc66c636adc75d5a32f7742a57d18eda1cd8
5
5
  SHA512:
6
- metadata.gz: 6b3e968d8cf97c9d11b14ff1d8374a64731ed3416d114ca0acf525a64f65843a35f0261da219d9f725e539eba7ac6460ba331a3aaf2b701b88627d841ac70a2a
7
- data.tar.gz: 61f80dadd2112d36c1c1ceaf88fe01fb5dcf68f22911e1535e68414ecdbaca07ca363365c7ec3032c2698aec1cb0929d054036d2b8ebf73d1c347093976f3bb4
6
+ metadata.gz: 1a856b89dd65fc126612d33aa276cfe49949484594a6d9c1cca2af01e91474883b1eb170e3f36647a125375ed645914cff6a1564cac3f955e2d7066a7c3c0e37
7
+ data.tar.gz: 2056b850ffddb607084f672018498b4f366a0ab7c0f97bf22e806355031eae146c256f2f62c4bc3ef5e769ee75a3bd05d2bfc56864a5994ea9b204a1a9b730ab
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.9.0 (2026-05-25)
4
+
5
+ ### Added
6
+ - **Models::Stateable**: Lightweight string-backed state machine — predicates (`draft?`, `published?`), scopes (`Article.draft`), direct setters (`published!`), guarded transitions (`publish!` / `may_publish?`) with optional `transitions:` config, generic `transition_to!`, and a configurable default applied via `after_initialize`. Supports `prefix:` / `suffix:` to avoid name clashes. Raises `Stateable::InvalidTransition` for disallowed state changes.
7
+ - **Controllers::Includable**: Whitelisted association sideloading + sparse fieldsets for JSON APIs. `includable :author, :comments, fields: { articles: %i[id title] }` declares an allow-list; `with_includes(relation)` applies only the requested, permitted associations; `requested_includes` / `requested_fields` return sanitized values to pass directly to serializers.
8
+ - **Models::SoftDeletable**: New scopes `with_deleted` (all records including deleted), `only_deleted` (alias for `soft_deleted`), `deleted_within(duration)` (recently deleted in a time window); new class method `restore_all` (bulk-restores all soft-deleted records).
9
+ - **Models::Publishable**: New scopes `scheduled` (future `published_at`) and `draft` (nil `published_at`); predicates `scheduled?` / `draft?`; mutator `publish_at!(time)` for scheduling a future publish; opt-in `publishable_by :published_at, default_scope: true` to hide unpublished records from `.all` (default is off).
10
+ - **Models::Searchable**: New options — `mode: :all` (AND all whitespace-separated terms across fields instead of OR), `match: :prefix` (anchors at start) / `match: :exact` (full match) / `match: :contains` (default, substring); option validation raises `ArgumentError` for unknown values.
11
+ - **Models::Sluggable**: New options — `history: true` (keeps slug history via the `friendly_id_slugs` table so old slugs still resolve) and `scope: :account_id` (slug uniqueness scoped to a foreign-key column). Both delegate to `friendly_id`'s built-in `:history` / `:scoped` modules.
12
+
13
+ ### Internal
14
+ - Extracted duplicated column-existence checks from all 11 model concerns into `ConcernsOnRails::Support::ColumnGuard`. Error messages are now unified: `"<Concern>: '<field>' does not exist in the database (table: <table>)"` — the `/does not exist/` substring is preserved across all concerns.
15
+ - Extracted duplicated random-value generation (`Hashable` `:custom` branch + `Tokenizable`) into `ConcernsOnRails::Support::RandomValue`. No behavior change.
16
+
3
17
  ## 1.8.2 (2026-05-22)
4
18
 
5
19
  ### Internal
data/README.md CHANGED
@@ -36,12 +36,14 @@ Article.published.without_deleted.find("hello-world")
36
36
  - [Searchable](#-searchable) — LIKE/ILIKE search across configured columns
37
37
  - [Activatable](#-activatable) — boolean active/inactive toggle
38
38
  - [Tokenizable](#-tokenizable) — security tokens with timing-safe lookup
39
+ - [Stateable](#-stateable) — lightweight string-backed state machine
39
40
  - **Controller concerns**
40
41
  - [Paginatable](#-paginatable) — offset pagination with headers
41
42
  - [Filterable](#-filterable) — declarative URL-param filters
42
43
  - [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
43
44
  - [Respondable](#-respondable) — standardized JSON envelopes
44
45
  - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
46
+ - [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
45
47
  - [Module paths & namespacing](#-module-paths--namespacing)
46
48
  - [Development](#-development)
47
49
  - [Contributing](#-contributing)
@@ -51,7 +53,7 @@ Article.published.without_deleted.find("hello-world")
51
53
 
52
54
  ## ✨ Why this gem?
53
55
 
54
- - **Eleven model concerns + five controller concerns**, all production-ready
56
+ - **Twelve model concerns + six controller concerns**, all production-ready
55
57
  - **One include, one macro** — no boilerplate, no glue code
56
58
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
57
59
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -64,7 +66,7 @@ Article.published.without_deleted.find("hello-world")
64
66
  Add to your application's `Gemfile`:
65
67
 
66
68
  ```ruby
67
- gem "concerns_on_rails", "~> 1.8"
69
+ gem "concerns_on_rails", "~> 1.9"
68
70
  ```
69
71
 
70
72
  Or pull the latest from GitHub:
@@ -145,10 +147,23 @@ post.slug # => "hello-world" (regenerates on title change)
145
147
  Post.friendly.find("hello-world")
146
148
  ```
147
149
 
150
+ **Options**
151
+
152
+ ```ruby
153
+ # Keep old slugs resolvable after a title change (needs a friendly_id_slugs migration)
154
+ sluggable_by :title, history: true
155
+ Post.friendly.find("old-slug") # still resolves to the renamed post
156
+
157
+ # Unique slug only within a scope column (same slug allowed in different accounts)
158
+ sluggable_by :title, scope: :account_id
159
+ ```
160
+
148
161
  **Notes**
149
162
  - Schema must have a `slug` column (string).
163
+ - `history: true` requires a `friendly_id_slugs` table — generate with `rails generate friendly_id` or add a manual migration.
164
+ - `scope: :col` requires `col` to exist in the same table.
150
165
  - Falls back to `to_s` if the configured source field doesn't respond.
151
- - Uses friendly_id's `:slugged` strategy under the hood.
166
+ - Uses friendly_id's `:slugged` (+ optionally `:history`, `:scoped`) strategies under the hood.
152
167
 
153
168
  ---
154
169
 
@@ -201,11 +216,29 @@ article.unpublish!
201
216
 
202
217
  Article.published # WHERE published_at <= NOW()
203
218
  Article.unpublished # WHERE published_at IS NULL OR published_at > NOW()
219
+ Article.scheduled # WHERE published_at > NOW() (future-dated — not live yet)
220
+ Article.draft # WHERE published_at IS NULL (no date set at all)
221
+ ```
222
+
223
+ **Scheduling**
224
+
225
+ ```ruby
226
+ article.publish_at!(1.week.from_now) # sets published_at to a future time
227
+ article.scheduled? # => true (not live yet)
228
+ article.draft? # => false
229
+ ```
230
+
231
+ **Opt-in default scope**
232
+
233
+ ```ruby
234
+ publishable_by :published_at, default_scope: true
235
+ # Article.all now returns only published records automatically
236
+ # Article.unscoped reaches everything
204
237
  ```
205
238
 
206
239
  **Notes**
207
- - "Published" means `published_at` is set **and** in the past — so scheduled posts (future `published_at`) stay unpublished until their time arrives.
208
- - No `default_scope` is added; chain `.published` explicitly.
240
+ - "Published" means `published_at` is set **and** in the past — so future-dated posts stay unpublished until their time arrives.
241
+ - No `default_scope` is added by default; chain `.published` explicitly (or opt in with `default_scope: true`).
209
242
 
210
243
  ---
211
244
 
@@ -232,11 +265,14 @@ user.really_delete! # bypasses callbacks, hard deletes from DB
232
265
  **Scopes**
233
266
 
234
267
  ```ruby
235
- User.active # 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)
268
+ User.active # alias of .without_deleted — non-deleted records
269
+ User.without_deleted # same
270
+ User.soft_deleted # only deleted records (timestamp set)
271
+ User.only_deleted # alias for .soft_deleted
272
+ User.with_deleted # all records — deleted + non-deleted
273
+ User.deleted_within(7.days) # soft-deleted within the last 7 days
274
+ User.all # default scope: non-deleted only
275
+ User.unscoped # everything (deleted + non-deleted)
240
276
  ```
241
277
 
242
278
  **Bulk operations**
@@ -244,6 +280,7 @@ User.unscoped # everything (deleted + non-deleted)
244
280
  ```ruby
245
281
  User.destroy_all # soft-deletes all matching records
246
282
  User.really_destroy_all # hard-deletes all matching records
283
+ User.restore_all # restores all soft-deleted records
247
284
  ```
248
285
 
249
286
  **Lifecycle hooks** — override these methods on the model:
@@ -449,11 +486,25 @@ Article.search("") # no-op — returns the full relation
449
486
  Article.search("foo").where(state: 1) # chainable like any scope
450
487
  ```
451
488
 
489
+ **Options**
490
+
491
+ ```ruby
492
+ # mode: :any (default) — any term in any field matches (OR)
493
+ # mode: :all — every whitespace-separated term must match somewhere (AND per term, OR across fields)
494
+ searchable_by :title, :body, mode: :all
495
+ Article.search("ruby framework") # title OR body must contain "ruby" AND "framework"
496
+
497
+ # match: :contains (default) — %term% (substring)
498
+ # match: :prefix — term% (starts with)
499
+ # match: :exact — term (full match)
500
+ searchable_by :sku, match: :prefix
501
+ ```
502
+
452
503
  **Notes**
453
504
  - Uses Arel's `matches`, which emits `ILIKE` on Postgres (case-insensitive) and `LIKE` elsewhere.
454
505
  - The query is escaped before interpolation — `%`, `_`, and `\` from user input are treated as literals, not wildcards.
455
- - Blank or nil queries return the relation unchanged so it's safe to drop into a controller pipeline.
456
- - 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.
457
508
 
458
509
  ---
459
510
 
@@ -526,6 +577,59 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
526
577
 
527
578
  ---
528
579
 
580
+ ## 🔄 Stateable
581
+
582
+ Lightweight string-backed state machine — the 80% of AASM without the dependency.
583
+
584
+ ```ruby
585
+ class Article < ApplicationRecord
586
+ include ConcernsOnRails::Stateable
587
+
588
+ stateable_by :status,
589
+ states: %i[draft pending published archived],
590
+ default: :draft,
591
+ transitions: {
592
+ publish: { from: %i[draft pending], to: :published },
593
+ archive: { to: :archived } # no :from → allowed from any state
594
+ }
595
+ end
596
+ ```
597
+
598
+ **Generated API**
599
+
600
+ ```ruby
601
+ article = Article.new # status defaults to "draft"
602
+ article.draft? # => true (predicate per state)
603
+ article.published? # => false
604
+
605
+ Article.draft # scope: WHERE status = 'draft'
606
+ Article.published # scope: WHERE status = 'published'
607
+
608
+ article.published! # direct setter — updates regardless of current state
609
+ article.publish! # guarded transition — raises InvalidTransition if not allowed
610
+ article.may_publish? # => true (guard check without raising)
611
+
612
+ article.transition_to!(:archived) # generic move to any declared state
613
+ ```
614
+
615
+ **Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
616
+
617
+ ```ruby
618
+ stateable_by :state, states: %i[open closed], prefix: true
619
+ # generates: state_open?, state_closed?, state_open!, state_closed!, State.state_open, …
620
+ ```
621
+
622
+ **Validation**
623
+ - Raises `ArgumentError` if the column, states, default, or transition config is invalid.
624
+ - Raises `Stateable::InvalidTransition` at runtime for disallowed guarded transitions.
625
+
626
+ **Notes**
627
+ - String-column backed (not integer-backed like Rails enum) — values are stored as-is.
628
+ - States like `active` / `expired` overlap with `Activatable`/`Expirable` scopes — use `prefix:` or `suffix:` to disambiguate.
629
+ - No persistence of transition history; combine with `Publishable` / `Schedulable` for time-based state tracking.
630
+
631
+ ---
632
+
529
633
  # 🎮 Controller Concerns
530
634
 
531
635
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -709,6 +813,46 @@ end
709
813
 
710
814
  ---
711
815
 
816
+ ## 🔗 Includable
817
+
818
+ Whitelisted association sideloading + sparse fieldsets for JSON APIs — zero arbitrary `.includes` from user input.
819
+
820
+ ```ruby
821
+ class ArticlesController < ApplicationController
822
+ include ConcernsOnRails::Controllers::Includable
823
+
824
+ includable :author, :comments,
825
+ fields: { articles: %i[id title published_at], authors: %i[id name] }
826
+
827
+ def index
828
+ render json: with_includes(Article.all),
829
+ include: requested_includes,
830
+ fields: requested_fields
831
+ end
832
+ end
833
+ ```
834
+
835
+ **URL params**
836
+
837
+ ```
838
+ GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=id,name
839
+ ```
840
+
841
+ **API**
842
+
843
+ | Method | What it does |
844
+ |----------------------|--------------------------------------------------------------------------------------------|
845
+ | `with_includes(rel)` | Parses `params[:include]`, intersects with the allow-list, calls `relation.includes(...)` |
846
+ | `requested_includes` | Returns the sanitized `[:author, :comments]` array (pass to `render json:, include:`) |
847
+ | `requested_fields` | Returns `{ articles: [:id, :title] }` sanitized map (pass to your serializer) |
848
+
849
+ **Notes**
850
+ - Non-whitelisted associations are **silently dropped** — no error, no arbitrary eager-loading.
851
+ - Non-whitelisted tables in `params[:fields]` are dropped; non-whitelisted columns within an allowed table are dropped.
852
+ - Pass `requested_fields` to your serializer (e.g. AMS / Blueprinter) — `Includable` itself does not alter the JSON output, only the query.
853
+
854
+ ---
855
+
712
856
  ## 🗂️ Module paths & namespacing
713
857
 
714
858
  Every concern is available under two paths:
@@ -740,7 +884,7 @@ Both forms reference the same module, so you can freely mix them.
740
884
  bundle install # install dev dependencies
741
885
  bundle exec rspec # run the test suite
742
886
  gem build concerns_on_rails.gemspec # build the gem
743
- gem install ./concerns_on_rails-1.7.0.gem # install locally
887
+ gem install ./concerns_on_rails-1.9.0.gem # install locally
744
888
  ```
745
889
 
746
890
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -0,0 +1,85 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Whitelisted association sideloading + sparse fieldsets for JSON APIs.
6
+ # Same allow-list philosophy as Controllers::Sortable: a client can only ask
7
+ # for associations/fields you've explicitly permitted, so `?include=` can
8
+ # never trigger an arbitrary `.includes` (N+1 / data-exposure risk).
9
+ #
10
+ # class ArticlesController < ApplicationController
11
+ # include ConcernsOnRails::Controllers::Includable
12
+ #
13
+ # includable :author, :comments,
14
+ # fields: { articles: %i[id title], authors: %i[id name] }
15
+ #
16
+ # def index
17
+ # render json: with_includes(Article.all),
18
+ # include: requested_includes,
19
+ # fields: requested_fields
20
+ # end
21
+ # end
22
+ #
23
+ # URL params:
24
+ # ?include=author,comments -> eager-loads only whitelisted associations
25
+ # ?fields[articles]=id,title -> sanitized down to the allowed columns
26
+ #
27
+ # `requested_includes` / `requested_fields` return sanitized values you can
28
+ # hand to your serializer; they never mutate the rendered output themselves.
29
+ module Includable
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ class_attribute :includable_associations, default: []
34
+ class_attribute :includable_fields, default: {}
35
+ end
36
+
37
+ class_methods do
38
+ # Whitelist sideloadable associations and (optionally) the columns
39
+ # exposable per resource via sparse fieldsets.
40
+ def includable(*associations, fields: {})
41
+ self.includable_associations = associations.map(&:to_sym)
42
+ self.includable_fields = fields.each_with_object({}) do |(table, cols), memo|
43
+ memo[table.to_sym] = Array(cols).map(&:to_sym)
44
+ end
45
+ end
46
+ end
47
+
48
+ # Eager-load only the whitelisted associations requested via ?include=.
49
+ # Returns the relation unchanged when nothing valid was requested.
50
+ def with_includes(relation)
51
+ associations = requested_includes
52
+ associations.empty? ? relation : relation.includes(*associations)
53
+ end
54
+
55
+ # Sanitized association list: requested ∩ allow-list.
56
+ def requested_includes
57
+ requested = params[:include].to_s.split(",").map { |token| token.strip.to_sym }
58
+ requested & self.class.includable_associations
59
+ end
60
+
61
+ # Sanitized sparse fieldsets: { table => [cols] }, each intersected with
62
+ # the allowed columns for that table. Unknown tables/columns are dropped.
63
+ def requested_fields
64
+ raw = params[:fields]
65
+ return {} unless raw.respond_to?(:each_pair)
66
+
67
+ allowed = self.class.includable_fields
68
+ raw.each_with_object({}) do |(table, cols), memo|
69
+ key = table.to_sym
70
+ next unless allowed.key?(key)
71
+
72
+ permitted = split_field_list(cols) & allowed[key]
73
+ memo[key] = permitted unless permitted.empty?
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def split_field_list(cols)
80
+ list = cols.is_a?(Array) ? cols : cols.to_s.split(",")
81
+ list.map { |col| col.to_s.strip.to_sym }
82
+ end
83
+ end
84
+ end
85
+ end
@@ -13,4 +13,5 @@ module ConcernsOnRails
13
13
  Searchable = Models::Searchable
14
14
  Activatable = Models::Activatable
15
15
  Tokenizable = Models::Tokenizable
16
+ Stateable = Models::Stateable
16
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
@@ -38,6 +38,8 @@ module ConcernsOnRails
38
38
  end
39
39
 
40
40
  class_methods do
41
+ include ConcernsOnRails::Support::ColumnGuard
42
+
41
43
  # Configure a tokenizable field.
42
44
  #
43
45
  # Options:
@@ -48,7 +50,8 @@ module ConcernsOnRails
48
50
  type = type.to_sym
49
51
  length = length.to_i
50
52
 
51
- validate_tokenizable_options!(field, type, length)
53
+ ensure_columns!("ConcernsOnRails::Models::Tokenizable", field)
54
+ validate_tokenizable_options!(type, length)
52
55
 
53
56
  # Build a fresh hash so subclasses don't mutate the parent's config.
54
57
  self.tokenizable_fields = tokenizable_fields.merge(field => { type: type, length: length })
@@ -69,8 +72,8 @@ module ConcernsOnRails
69
72
  case config[:type]
70
73
  when :urlsafe then SecureRandom.urlsafe_base64(length)[0, length]
71
74
  when :hex then SecureRandom.hex((length + 1) / 2)[0, length]
72
- when :alphanumeric then random_string_from_alphabet(ALPHANUMERIC_ALPHABET, length)
73
- when :numeric then random_string_from_alphabet(NUMERIC_ALPHABET, 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)
74
77
  end
75
78
  end
76
79
  end
@@ -100,12 +103,7 @@ module ConcernsOnRails
100
103
  end
101
104
 
102
105
  class_methods do
103
- def validate_tokenizable_options!(field, type, length)
104
- unless column_names.include?(field.to_s)
105
- raise ArgumentError,
106
- "ConcernsOnRails::Models::Tokenizable: tokenizable field '#{field}' does not exist in the database"
107
- end
108
-
106
+ def validate_tokenizable_options!(type, length)
109
107
  unless VALID_TYPES.include?(type)
110
108
  raise ArgumentError,
111
109
  "ConcernsOnRails::Models::Tokenizable: unknown type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
@@ -116,11 +114,7 @@ module ConcernsOnRails
116
114
  raise ArgumentError, "ConcernsOnRails::Models::Tokenizable: length must be a positive integer"
117
115
  end
118
116
 
119
- def random_string_from_alphabet(alphabet, length)
120
- Array.new(length) { alphabet[SecureRandom.random_number(alphabet.size)] }.join
121
- end
122
-
123
- private :validate_tokenizable_options!, :random_string_from_alphabet
117
+ private :validate_tokenizable_options!
124
118
  end
125
119
 
126
120
  # Assigns the generated value only when blank, so callers can pass an explicit one.
@@ -0,0 +1,32 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Support
5
+ # Shared schema-validation helper mixed into a concern's ClassMethods.
6
+ # Runs in class context, so `column_names` / `table_name` resolve against
7
+ # the including model. Centralizes the column-existence check that every
8
+ # model concern used to re-implement, and keeps the error wording uniform.
9
+ #
10
+ # class_methods do
11
+ # include ConcernsOnRails::Support::ColumnGuard
12
+ #
13
+ # def activatable_by(field = :active)
14
+ # self.activatable_field = field.to_sym
15
+ # ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field)
16
+ # end
17
+ # end
18
+ #
19
+ # The phrase "does not exist" is preserved so existing specs that match
20
+ # /does not exist/ keep passing.
21
+ module ColumnGuard
22
+ def ensure_columns!(concern, *fields)
23
+ fields.flatten.compact.each do |field|
24
+ next if column_names.include?(field.to_s)
25
+
26
+ raise ArgumentError,
27
+ "#{concern}: '#{field}' does not exist in the database (table: #{table_name})"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ require "securerandom"
2
+
3
+ module ConcernsOnRails
4
+ module Support
5
+ # Shared random-value generation used by Hashable (:custom) and Tokenizable
6
+ # (:alphanumeric / :numeric). Samples `length` characters uniformly from
7
+ # `alphabet` using SecureRandom.
8
+ module RandomValue
9
+ module_function
10
+
11
+ def from_alphabet(alphabet, length)
12
+ Array.new(length) { alphabet[SecureRandom.random_number(alphabet.size)] }.join
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.8.2".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"
@@ -18,6 +23,7 @@ require "concerns_on_rails/models/normalizable"
18
23
  require "concerns_on_rails/models/searchable"
19
24
  require "concerns_on_rails/models/activatable"
20
25
  require "concerns_on_rails/models/tokenizable"
26
+ require "concerns_on_rails/models/stateable"
21
27
 
22
28
  # Controller concerns
23
29
  require "concerns_on_rails/controllers/paginatable"
@@ -25,6 +31,7 @@ require "concerns_on_rails/controllers/filterable"
25
31
  require "concerns_on_rails/controllers/sortable"
26
32
  require "concerns_on_rails/controllers/respondable"
27
33
  require "concerns_on_rails/controllers/error_handleable"
34
+ require "concerns_on_rails/controllers/includable"
28
35
 
29
36
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
30
37
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.2
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-22 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,7 +87,10 @@ files:
86
87
  - lib/concerns_on_rails/models/sluggable.rb
87
88
  - lib/concerns_on_rails/models/soft_deletable.rb
88
89
  - lib/concerns_on_rails/models/sortable.rb
90
+ - lib/concerns_on_rails/models/stateable.rb
89
91
  - lib/concerns_on_rails/models/tokenizable.rb
92
+ - lib/concerns_on_rails/support/column_guard.rb
93
+ - lib/concerns_on_rails/support/random_value.rb
90
94
  - lib/concerns_on_rails/version.rb
91
95
  homepage: https://github.com/VSN2015/concerns_on_rails
92
96
  licenses: