concerns_on_rails 1.23.0 → 1.24.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +27 -6
  4. data/lib/concerns_on_rails/configuration.rb +27 -0
  5. data/lib/concerns_on_rails/controllers/idempotentable.rb +7 -4
  6. data/lib/concerns_on_rails/controllers/throttleable.rb +7 -3
  7. data/lib/concerns_on_rails/legacy_aliases.rb +25 -29
  8. data/lib/concerns_on_rails/models/activatable.rb +1 -1
  9. data/lib/concerns_on_rails/models/addressable.rb +1 -1
  10. data/lib/concerns_on_rails/models/auditable.rb +1 -1
  11. data/lib/concerns_on_rails/models/counter_cacheable.rb +1 -1
  12. data/lib/concerns_on_rails/models/encryptable.rb +2 -2
  13. data/lib/concerns_on_rails/models/expirable.rb +1 -1
  14. data/lib/concerns_on_rails/models/hashable.rb +2 -1
  15. data/lib/concerns_on_rails/models/lockable.rb +2 -1
  16. data/lib/concerns_on_rails/models/monetizable.rb +1 -1
  17. data/lib/concerns_on_rails/models/publishable.rb +1 -1
  18. data/lib/concerns_on_rails/models/schedulable.rb +1 -1
  19. data/lib/concerns_on_rails/models/sequenceable.rb +3 -3
  20. data/lib/concerns_on_rails/models/sluggable.rb +12 -2
  21. data/lib/concerns_on_rails/models/soft_deletable.rb +1 -1
  22. data/lib/concerns_on_rails/models/sortable.rb +11 -2
  23. data/lib/concerns_on_rails/models/stateable.rb +1 -1
  24. data/lib/concerns_on_rails/models/storable.rb +1 -1
  25. data/lib/concerns_on_rails/models/taggable.rb +1 -1
  26. data/lib/concerns_on_rails/models/tokenizable.rb +1 -1
  27. data/lib/concerns_on_rails/support/column_guard.rb +23 -4
  28. data/lib/concerns_on_rails/version.rb +1 -1
  29. data/lib/concerns_on_rails.rb +95 -67
  30. metadata +52 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba3e5dc1bc6af51ec2bd4e29946973c5fc68736d4c1994fdbd472874f965c4bc
4
- data.tar.gz: 16b2372635abe33484efabcae5407d1debe5a0627df9df0496a04e930c46ec65
3
+ metadata.gz: a5361fdba6417a0a46213e0e8e41f8ced13d00f76cb17f102404d1d794a98a39
4
+ data.tar.gz: 0fab62907022084c9b54d1cca52f8b4eeefbecced0a4bc905e4b3a8a55f8f582
5
5
  SHA512:
6
- metadata.gz: 8a159b0026bca1ebc72dbc11c45e875caaa19ad2ec0615ee658abd5b3e8ab53e0bb309d816a5371ddba6bad5c609b82a76228e66d678d3346d624021634eddf5
7
- data.tar.gz: da89fb098f9e17fd9ae49662efdb027efb64958007bf790ee895c0faf39a1c9f2cb76d2e95a4b0b3b075bcb404fe926c36d970f78ca152bdd287be81e7a3a991
6
+ metadata.gz: 5654215000d983a63260507d6741090add3b7861e200e81ebd180ad7d7de61891c0646c49b666d4b01b179b51ae88d1da73386ffb35bc1cbb24328d8f3238c99
7
+ data.tar.gz: 436b80fa129a4a3922a0a6b7ba4945f90205f1071a4d190875fa26e87458bb39ba483b0f682bff0c64cd3fb09fda79ec15cec776f22b3d085af03d14fe5a2275
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.24.0 (2026-08-15)
4
+
5
+ A developer-experience wave from the 2026-08-15 usability review: leaner install, lazy loading, teaching errors, and one-initializer store configuration. No behavior changes for configured concerns. 1084 examples, 0 failures.
6
+
7
+ ### Changed
8
+ - **Dependencies**: the gem now depends on `actionpack` / `activerecord` / `activesupport` instead of the full `rails` meta-gem (API-only hosts stop pulling Action Cable / Mailbox / Text), and the `acts_as_list` constraint is `>= 0.7.5, < 2` — the old `~> 0.7.5` pin was an unresolvable Bundler conflict for any app already on acts_as_list 1.x. Suite verified against acts_as_list 1.2.6.
9
+ - **Lazy loading**: `require "concerns_on_rails"` no longer eagerly loads all 40 concern/support files — everything is autoloaded on first constant reference, and `friendly_id` / `acts_as_list` load only when Sluggable / Sortable is actually used (a missing gem raises `ConcernsOnRails::MissingDependency`, a `LoadError` subclass whose message names the Gemfile line to add). The pre-1.6 top-level aliases (`ConcernsOnRails::Sluggable`, …) keep working, now resolved lazily via `const_missing`.
10
+ - **Support::ColumnGuard** (all model concerns): the missing-column `ArgumentError` now teaches the fix — it appends a ready-to-paste `bin/rails generate migration ...` command carrying the concern's expected column type (e.g. `AddDeletedAtToArticles deleted_at:datetime`; token/slug columns suggest `string:uniq`, Encryptable blind-index columns `string:index`).
11
+
12
+ ### Added
13
+ - **`ConcernsOnRails.setup`**: gem-wide configuration entry point (`lib/concerns_on_rails/configuration.rb`). First setting: `config.cache_store` — a store or a Proc (e.g. `-> { Rails.cache }`) used as the fallback store by `Controllers::Throttleable` and `Controllers::Idempotentable`, so one initializer line replaces per-controller `self.throttleable_store = ...` / `self.idempotency_store = ...` wiring. A class-level store still wins; with neither configured the concerns raise exactly as before.
14
+
3
15
  ## 1.23.0 (2026-07-26)
4
16
 
5
17
  Two new model concerns — the roadmap picks after 1.22's fixes round: the right-to-erasure capability that completes the sensitive-data suite, and the concern-aware deep copy that makes "duplicate this invoice" safe next to unique slugs, tokens, and sequence numbers. 1075 examples, 0 failures.
data/README.md CHANGED
@@ -12,7 +12,7 @@ One `include`, one declarative macro — done.
12
12
  [![Rails](https://img.shields.io/badge/rails-5.0--8.x-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
13
13
  [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](#-license)
14
14
 
15
- 🧩 **23 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated**
15
+ 🧩 **26 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated**
16
16
 
17
17
  </div>
18
18
 
@@ -96,7 +96,7 @@ Article.published.without_deleted.find("hello-world")
96
96
 
97
97
  - **Twenty-six model concerns + sixteen controller concerns**, all production-ready
98
98
  - **One include, one macro** — no boilerplate, no glue code
99
- - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
99
+ - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
100
100
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
101
101
  - **Composable** — concerns are independent; mix and match per model
102
102
 
@@ -107,7 +107,7 @@ Article.published.without_deleted.find("hello-world")
107
107
  Add to your application's `Gemfile`:
108
108
 
109
109
  ```ruby
110
- gem "concerns_on_rails", "~> 1.23"
110
+ gem "concerns_on_rails", "~> 1.24"
111
111
  ```
112
112
 
113
113
  Or pull the latest from GitHub:
@@ -122,6 +122,27 @@ Then run:
122
122
  bundle install
123
123
  ```
124
124
 
125
+ ### Optional: one-initializer configuration
126
+
127
+ Everything works with zero configuration. The store-backed controller concerns
128
+ (`Throttleable`, `Idempotentable`) need a cache store — set it once, gem-wide,
129
+ instead of per controller class:
130
+
131
+ ```ruby
132
+ # config/initializers/concerns_on_rails.rb
133
+ ConcernsOnRails.setup do |config|
134
+ config.cache_store = -> { Rails.cache } # fallback for Throttleable / Idempotentable
135
+ end
136
+
137
+ # Encryptable's key lives in its own config (see the Encryptable section):
138
+ ConcernsOnRails.configure_encryption do |c|
139
+ c.key = -> { Rails.application.credentials.dig(:encryption, :key) }
140
+ end
141
+ ```
142
+
143
+ A per-class `self.throttleable_store = ...` / `self.idempotency_store = ...`
144
+ still wins over the gem-wide fallback.
145
+
125
146
  ---
126
147
 
127
148
  ## 🧪 Compatibility
@@ -1599,7 +1620,7 @@ Fixed-window counter: the key embeds a floored time bucket (`epoch / period`) so
1599
1620
 
1600
1621
  **Notes**
1601
1622
  - The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
1602
- - There is **no in-process default store** on purpose: the first throttled request raises `ArgumentError` until you set `throttleable_store`, so you never silently rate-limit per-process.
1623
+ - There is **no in-process default store** on purpose: the first throttled request raises `ArgumentError` until you set `throttleable_store` (or the gem-wide fallback `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`), so you never silently rate-limit per-process.
1603
1624
  - When `Respondable` is included, the 429 body delegates to `render_error` (`code: "rate_limited"`).
1604
1625
  - Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 5.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach for [`rack-attack`](https://github.com/rack/rack-attack).
1605
1626
 
@@ -1648,7 +1669,7 @@ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) →
1648
1669
 
1649
1670
  **Notes**
1650
1671
  - Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
1651
- - There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store`.
1672
+ - There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store` (or the gem-wide fallback `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`).
1652
1673
  - When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
1653
1674
  - Declare halting filters (authentication, `Throttleable`) **before** including this concern — a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered by `rescue_from` handlers are never cached.
1654
1675
  - Keys must be ≤255 chars with no control characters (the raw key is echoed in `X-Idempotency-Key`); set `lock_ttl:` above the slowest declared action's worst case.
@@ -1813,7 +1834,7 @@ Both forms reference the same module, so you can freely mix them.
1813
1834
  bundle install # install dev dependencies
1814
1835
  bundle exec rspec # run the test suite
1815
1836
  gem build concerns_on_rails.gemspec # build the gem
1816
- gem install ./concerns_on_rails-1.23.0.gem # install locally
1837
+ gem install ./concerns_on_rails-1.24.0.gem # install locally
1817
1838
  ```
1818
1839
 
1819
1840
  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,27 @@
1
+ module ConcernsOnRails
2
+ # Gem-wide configuration, set once from an initializer:
3
+ #
4
+ # ConcernsOnRails.setup do |config|
5
+ # config.cache_store = -> { Rails.cache }
6
+ # end
7
+ #
8
+ # `cache_store` is the fallback store consulted by Controllers::Throttleable
9
+ # and Controllers::Idempotentable when the controller class hasn't set its
10
+ # own (`self.throttleable_store = ...` / `self.idempotency_store = ...`
11
+ # still win). A store object or a zero-arg callable — prefer a Proc so
12
+ # `Rails.cache` is read lazily, after the framework has booted. The store
13
+ # contract is unchanged: atomic #increment for throttling, #read /
14
+ # #write(expires_in:, unless_exist:) / #delete for idempotency. There is
15
+ # still no in-process default on purpose — a non-atomic store silently
16
+ # under-counts, so the host must opt in explicitly (just once, here).
17
+ class Configuration
18
+ attr_accessor :cache_store
19
+
20
+ # The fallback store with any callable resolved (per lookup, so a Proc
21
+ # reading Rails.cache follows a swapped-out cache in tests). nil when the
22
+ # host never configured one.
23
+ def resolved_cache_store
24
+ cache_store.respond_to?(:call) ? cache_store.call : cache_store
25
+ end
26
+ end
27
+ end
@@ -33,8 +33,10 @@ module ConcernsOnRails
33
33
  # The claim is taken atomically via `write(..., unless_exist: true)`
34
34
  # (memcached `add` / Redis `SET NX` through Rails.cache); a store without
35
35
  # that atomicity is best-effort under concurrency. There is no in-process
36
- # default store on purpose — configure one explicitly or the first keyed
37
- # request raises ArgumentError. Note that responses rendered by
36
+ # default store on purpose — configure one explicitly (per controller as
37
+ # above, or once for the whole app via `ConcernsOnRails.setup { |c|
38
+ # c.cache_store = -> { Rails.cache } }`) or the first keyed request raises
39
+ # ArgumentError. Note that responses rendered by
38
40
  # `rescue_from` handlers bypass the around filter's success path and are
39
41
  # never cached.
40
42
  #
@@ -246,12 +248,13 @@ module ConcernsOnRails
246
248
  end
247
249
 
248
250
  def idempotency_store!
249
- store = self.class.idempotency_store
251
+ store = self.class.idempotency_store || ConcernsOnRails.config.resolved_cache_store
250
252
  return store if store
251
253
 
252
254
  raise ArgumentError,
253
255
  "ConcernsOnRails::Controllers::Idempotentable: no store configured. " \
254
- "Set `self.idempotency_store = Rails.cache` " \
256
+ "Set `self.idempotency_store = Rails.cache` on the controller, or the gem-wide " \
257
+ "fallback: ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } } " \
255
258
  "(must support #read, #write(expires_in:, unless_exist:) and #delete)."
256
259
  end
257
260
 
@@ -23,7 +23,9 @@ module ConcernsOnRails
23
23
  # exact. The store MUST support atomic increment-with-expiry (`Rails.cache`
24
24
  # with `#increment`, or Redis); a non-atomic store under-counts under
25
25
  # concurrency. There is no in-process default store on purpose — configure
26
- # one explicitly or the first throttled request raises ArgumentError.
26
+ # one explicitly (per controller as above, or once for the whole app via
27
+ # `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`) or
28
+ # the first throttled request raises ArgumentError.
27
29
  module Throttleable
28
30
  extend ActiveSupport::Concern
29
31
 
@@ -161,12 +163,14 @@ module ConcernsOnRails
161
163
  end
162
164
 
163
165
  def throttle_store!
164
- store = self.class.throttleable_store
166
+ store = self.class.throttleable_store || ConcernsOnRails.config.resolved_cache_store
165
167
  return store if store
166
168
 
167
169
  raise ArgumentError,
168
170
  "ConcernsOnRails::Controllers::Throttleable: no store configured. " \
169
- "Set `self.throttleable_store = Rails.cache` (must support atomic #increment)."
171
+ "Set `self.throttleable_store = Rails.cache` on the controller, or the gem-wide " \
172
+ "fallback: ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } } " \
173
+ "(must support atomic #increment)."
170
174
  end
171
175
 
172
176
  def throttle_action_name
@@ -1,31 +1,27 @@
1
1
  module ConcernsOnRails
2
- # Backwards-compatibility aliases for pre-1.6 module paths.
3
- # Existing apps doing `include ConcernsOnRails::Sluggable` continue to work;
4
- # new code is encouraged to use the namespaced form: `ConcernsOnRails::Models::Sluggable`.
5
- Sluggable = Models::Sluggable
6
- Sortable = Models::Sortable
7
- Publishable = Models::Publishable
8
- SoftDeletable = Models::SoftDeletable
9
- Hashable = Models::Hashable
10
- Schedulable = Models::Schedulable
11
- Expirable = Models::Expirable
12
- Normalizable = Models::Normalizable
13
- Searchable = Models::Searchable
14
- Activatable = Models::Activatable
15
- Tokenizable = Models::Tokenizable
16
- Stateable = Models::Stateable
17
- Addressable = Models::Addressable
18
- Sequenceable = Models::Sequenceable
19
- Taggable = Models::Taggable
20
- Sanitizable = Models::Sanitizable
21
- Maskable = Models::Maskable
22
- Monetizable = Models::Monetizable
23
- Auditable = Models::Auditable
24
- Lockable = Models::Lockable
25
- Aliasable = Models::Aliasable
26
- Storable = Models::Storable
27
- CounterCacheable = Models::CounterCacheable
28
- Encryptable = Models::Encryptable
29
- Anonymizable = Models::Anonymizable
30
- Duplicable = Models::Duplicable
2
+ # Backwards-compatibility aliases for pre-1.6 module paths, resolved lazily:
3
+ # eager `Sluggable = Models::Sluggable` assignments would force-load every
4
+ # concern file at boot and defeat the loader's autoload laziness. The first
5
+ # reference to `ConcernsOnRails::<Name>` loads the real module and pins the
6
+ # alias constant, so `include ConcernsOnRails::Sluggable` works unchanged.
7
+ # New code is encouraged to use the namespaced form:
8
+ # `ConcernsOnRails::Models::Sluggable`.
9
+ #
10
+ # Note `Sortable` resolves to Models::Sortable (as it always has) — the
11
+ # controller concern is only reachable as Controllers::Sortable.
12
+ LEGACY_MODEL_ALIASES = %i[
13
+ Sluggable Sortable Publishable SoftDeletable Hashable Schedulable
14
+ Expirable Normalizable Searchable Activatable Tokenizable Stateable
15
+ Addressable Sequenceable Taggable Sanitizable Maskable Monetizable
16
+ Auditable Lockable Aliasable Storable CounterCacheable Encryptable
17
+ Anonymizable Duplicable
18
+ ].freeze
19
+
20
+ def self.const_missing(name)
21
+ return super unless LEGACY_MODEL_ALIASES.include?(name)
22
+
23
+ # Idempotent under a concurrent first reference: both threads pin the
24
+ # same module object, and once pinned const_missing never fires again.
25
+ const_set(name, Models.const_get(name))
26
+ end
31
27
  end
@@ -33,7 +33,7 @@ module ConcernsOnRails
33
33
 
34
34
  def activatable_by(field = DEFAULT_FIELD, prefix: nil, suffix: nil)
35
35
  self.activatable_field = field.to_sym
36
- ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field)
36
+ ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field, types: :boolean)
37
37
 
38
38
  # Affix the scope names so two concerns that each define `.active`
39
39
  # (e.g. SoftDeletable / Expirable) can coexist on one model.
@@ -86,7 +86,7 @@ module ConcernsOnRails
86
86
  raise ArgumentError, "#{LABEL}: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
87
87
 
88
88
  overrides = mapping.to_h { |part, column| [part.to_sym, column.to_sym] }
89
- ensure_columns!(LABEL, overrides.values)
89
+ ensure_columns!(LABEL, overrides.values, types: :string)
90
90
  DEFAULT_FIELDS.merge(overrides).select { |_part, column| column_names.include?(column.to_s) }
91
91
  end
92
92
 
@@ -74,7 +74,7 @@ module ConcernsOnRails
74
74
  self.auditable_actor = actor
75
75
  self.auditable_max_entries = max_entries
76
76
  self.auditable_max_value_length = max_value_length
77
- ensure_columns!(LABEL, into, *fields)
77
+ ensure_columns!(LABEL, into, *fields, types: { into => :text })
78
78
  auditable_guard_encryptable!(fields)
79
79
 
80
80
  before_save :auditable_capture_changes
@@ -139,7 +139,7 @@ module ConcernsOnRails
139
139
  end
140
140
  return unless klass
141
141
 
142
- ensure_columns_on!(LABEL, klass, count_column)
142
+ ensure_columns_on!(LABEL, klass, count_column, types: :integer)
143
143
  end
144
144
 
145
145
  def counter_cacheable_recount_rule(rule)
@@ -232,13 +232,13 @@ module ConcernsOnRails
232
232
  def encryptable(*fields, type: :string, key: nil, blind_index: nil)
233
233
  type = type.to_sym
234
234
  encryptable_validate!(fields, type, blind_index)
235
- ensure_columns!(LABEL, *fields)
235
+ ensure_columns!(LABEL, *fields, types: :text)
236
236
 
237
237
  fields.each do |field|
238
238
  field = field.to_sym
239
239
  encryptable_guard_auditable!(field)
240
240
  bi = encryptable_normalize_blind_index(field, blind_index)
241
- ensure_columns!(LABEL, bi[:column]) if bi
241
+ ensure_columns!(LABEL, bi[:column], types: "string:index") if bi
242
242
  self.encryptable_rules = encryptable_rules.merge(field => { type: type, key: key, blind_index: bi })
243
243
  attribute field, EncryptedType.new(type: type, key: key)
244
244
  encryptable_define_helpers(field)
@@ -21,7 +21,7 @@ module ConcernsOnRails
21
21
  # expirable_by :valid_until
22
22
  def expirable_by(field = DEFAULT_FIELD, prefix: nil, suffix: nil)
23
23
  self.expirable_field = field.to_sym
24
- ensure_columns!("ConcernsOnRails::Models::Expirable", expirable_field)
24
+ ensure_columns!("ConcernsOnRails::Models::Expirable", expirable_field, types: :datetime)
25
25
  define_expirable_scopes(prefix, suffix)
26
26
  end
27
27
 
@@ -37,7 +37,8 @@ module ConcernsOnRails
37
37
  self.hashable_alphabet = alphabet
38
38
  self.hashable_unique = unique
39
39
 
40
- ensure_columns!("ConcernsOnRails::Models::Hashable", hashable_field)
40
+ ensure_columns!("ConcernsOnRails::Models::Hashable", hashable_field,
41
+ types: hashable_unique ? "string:uniq" : :string)
41
42
  validate_hashable_options!
42
43
  before_create :assign_hashable_value
43
44
 
@@ -75,7 +75,8 @@ module ConcernsOnRails
75
75
  self.lockable_locked_at_field = locked_at
76
76
  self.lockable_max_attempts = max_attempts
77
77
  self.lockable_unlock_in = unlock_in
78
- ensure_columns!(LABEL, attempts, locked_at)
78
+ ensure_columns!(LABEL, attempts, locked_at,
79
+ types: { attempts => :integer, locked_at => :datetime })
79
80
  validate_lockable_attempts_column!(attempts)
80
81
  define_lockable_scopes(prefix, suffix)
81
82
  end
@@ -47,7 +47,7 @@ module ConcernsOnRails
47
47
  raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :subunit_to_unit must be a positive integer"
48
48
  end
49
49
 
50
- ensure_columns!("ConcernsOnRails::Models::Monetizable", fields)
50
+ ensure_columns!("ConcernsOnRails::Models::Monetizable", fields, types: :integer)
51
51
  config = { unit: unit, precision: precision, delimiter: delimiter, separator: separator, subunit_to_unit: subunit_to_unit }
52
52
  fields.each { |cents_field| define_money_accessors(cents_field.to_sym, as, config) }
53
53
  end
@@ -53,7 +53,7 @@ module ConcernsOnRails
53
53
  def publishable_by(field = nil, default_scope: false)
54
54
  self.publishable_field = field || :published_at
55
55
  @publishable_boolean_column = nil
56
- ensure_columns!("ConcernsOnRails::Models::Publishable", publishable_field)
56
+ ensure_columns!("ConcernsOnRails::Models::Publishable", publishable_field, types: :datetime)
57
57
  enable_published_default_scope if default_scope
58
58
  end
59
59
 
@@ -56,7 +56,7 @@ module ConcernsOnRails
56
56
  end
57
57
 
58
58
  ensure_columns!("ConcernsOnRails::Models::Schedulable",
59
- schedulable_starts_at_field, schedulable_ends_at_field)
59
+ schedulable_starts_at_field, schedulable_ends_at_field, types: :datetime)
60
60
  end
61
61
  end
62
62
 
@@ -62,10 +62,10 @@ module ConcernsOnRails
62
62
  reset = reset.to_sym
63
63
  scope_cols = Array(scope).map(&:to_sym)
64
64
 
65
- ensure_columns!(NAME, field)
66
- ensure_columns!(NAME, into) if into
65
+ ensure_columns!(NAME, field, types: :integer)
66
+ ensure_columns!(NAME, into, types: :string) if into
67
67
  ensure_columns!(NAME, *scope_cols) unless scope_cols.empty?
68
- ensure_columns!(NAME, :created_at) unless reset == :never
68
+ ensure_columns!(NAME, :created_at, types: :datetime) unless reset == :never
69
69
  validate_sequenceable_options!(reset, template)
70
70
 
71
71
  self.sequenceable_config = sequenceable_config.merge(
@@ -1,6 +1,15 @@
1
1
  require "active_support/concern"
2
2
  require "concerns_on_rails/support/column_guard"
3
- require "friendly_id"
3
+
4
+ # Loaded here — with the concern, on first use — rather than at gem boot, so
5
+ # apps that never include Sluggable never load friendly_id.
6
+ begin
7
+ require "friendly_id"
8
+ rescue LoadError
9
+ raise ConcernsOnRails::MissingDependency,
10
+ "ConcernsOnRails::Models::Sluggable requires the friendly_id gem. " \
11
+ 'Add `gem "friendly_id", "~> 5.4"` to your Gemfile to use it.'
12
+ end
4
13
 
5
14
  module ConcernsOnRails
6
15
  module Models
@@ -59,7 +68,8 @@ module ConcernsOnRails
59
68
  # first save with an opaque friendly_id error instead of this
60
69
  # concern's clear ArgumentError.
61
70
  ensure_columns!("ConcernsOnRails::Models::Sluggable",
62
- [sluggable_field, friendly_id_config.slug_column, scope].compact)
71
+ [sluggable_field, friendly_id_config.slug_column, scope].compact,
72
+ types: { friendly_id_config.slug_column.to_sym => "string:uniq" })
63
73
  return unless history || scope || reserved_words || finders
64
74
 
65
75
  reconfigure_friendly_id(history: history, scope: scope,
@@ -50,7 +50,7 @@ module ConcernsOnRails
50
50
  self.soft_delete_field = field || :deleted_at
51
51
  self.soft_delete_touch = touch
52
52
  self.soft_delete_default_scope = default_scope
53
- ensure_columns!("ConcernsOnRails::Models::SoftDeletable", soft_delete_field)
53
+ ensure_columns!("ConcernsOnRails::Models::SoftDeletable", soft_delete_field, types: :datetime)
54
54
  end
55
55
 
56
56
  # Soft-delete every matching record. Returns the Integer count of
@@ -1,6 +1,15 @@
1
1
  require "active_support/concern"
2
2
  require "concerns_on_rails/support/column_guard"
3
- require "acts_as_list"
3
+
4
+ # Loaded here — with the concern, on first use — rather than at gem boot, so
5
+ # apps that never include Sortable never load acts_as_list.
6
+ begin
7
+ require "acts_as_list"
8
+ rescue LoadError
9
+ raise ConcernsOnRails::MissingDependency,
10
+ "ConcernsOnRails::Models::Sortable requires the acts_as_list gem. " \
11
+ 'Add `gem "acts_as_list"` to your Gemfile to use it.'
12
+ end
4
13
 
5
14
  module ConcernsOnRails
6
15
  module Models
@@ -62,7 +71,7 @@ module ConcernsOnRails
62
71
  self.sortable_field = field
63
72
  self.sortable_direction = direction
64
73
 
65
- ensure_columns!("ConcernsOnRails::Models::Sortable", sortable_field)
74
+ ensure_columns!("ConcernsOnRails::Models::Sortable", sortable_field, types: :integer)
66
75
 
67
76
  return unless use_acts_as_list
68
77
 
@@ -90,7 +90,7 @@ module ConcernsOnRails
90
90
  self.stateable_transitions = options[:transitions] || {}
91
91
  self.stateable_prefix = stateable_affix(options[:prefix])
92
92
  self.stateable_suffix = stateable_affix(options[:suffix])
93
- ensure_columns!(LABEL, stateable_field)
93
+ ensure_columns!(LABEL, stateable_field, types: :string)
94
94
  end
95
95
 
96
96
  # `true` => use the field name; a string/symbol => use it literally; else none.
@@ -104,7 +104,7 @@ module ConcernsOnRails
104
104
  # named `prefix`/`suffix`. See the module docs.
105
105
  def storable_by(column, keys = {}, prefix: nil, suffix: nil, **kw_keys)
106
106
  column = column.to_sym
107
- ensure_columns!(LABEL, column)
107
+ ensure_columns!(LABEL, column, types: :text)
108
108
 
109
109
  prepared = storable_merge_key_specs(keys, kw_keys).map do |key, raw_spec|
110
110
  key = key.to_sym
@@ -53,7 +53,7 @@ module ConcernsOnRails
53
53
  self.taggable_field = field.to_sym
54
54
  self.taggable_delimiter = delimiter.to_s
55
55
  self.taggable_downcase = downcase
56
- ensure_columns!(LABEL, taggable_field)
56
+ ensure_columns!(LABEL, taggable_field, types: :string)
57
57
 
58
58
  before_validation :taggable_normalize!
59
59
  end
@@ -56,7 +56,7 @@ module ConcernsOnRails
56
56
  type = type.to_sym
57
57
  length = length.to_i
58
58
 
59
- ensure_columns!("ConcernsOnRails::Models::Tokenizable", field)
59
+ ensure_columns!("ConcernsOnRails::Models::Tokenizable", field, types: "string:uniq")
60
60
  validate_tokenizable_options!(type, length)
61
61
 
62
62
  # Build a fresh hash so subclasses don't mutate the parent's config.
@@ -1,3 +1,5 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
1
3
  module ConcernsOnRails
2
4
  module Support
3
5
  # Shared schema-validation helper mixed into a concern's ClassMethods.
@@ -27,24 +29,41 @@ module ConcernsOnRails
27
29
  # The phrase "does not exist" is preserved so existing specs that match
28
30
  # /does not exist/ keep passing.
29
31
  module ColumnGuard
30
- def ensure_columns!(concern, *fields)
31
- ensure_columns_on!(concern, self, *fields)
32
+ # `types:` teaches the error message: a Symbol/String applies to every
33
+ # listed field, a Hash maps field => type. When present, the raised
34
+ # ArgumentError appends a ready-to-paste migration command. Generator
35
+ # column-modifier syntax is welcome ("string:uniq" — tokens/slugs want a
36
+ # unique index anyway).
37
+ def ensure_columns!(concern, *fields, types: nil)
38
+ ensure_columns_on!(concern, self, *fields, types: types)
32
39
  end
33
40
 
34
41
  # Same contract, validated against another class (e.g. CounterCacheable
35
42
  # checks the counter column on the *parent* model).
36
- def ensure_columns_on!(concern, klass, *fields)
43
+ def ensure_columns_on!(concern, klass, *fields, types: nil)
37
44
  return false unless schema_reachable?(klass)
38
45
 
39
46
  fields.flatten.compact.each do |field|
40
47
  next if klass.column_names.include?(field.to_s)
41
48
 
42
49
  raise ArgumentError,
43
- "#{concern}: '#{field}' does not exist in the database (table: #{klass.table_name})"
50
+ "#{concern}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
51
+ "#{column_migration_hint(klass, field, types)}"
44
52
  end
45
53
  true
46
54
  end
47
55
 
56
+ # " Add it with: bin/rails generate migration AddDeletedAtToArticles
57
+ # deleted_at:datetime" — every missing-column failure becomes a
58
+ # copy-paste fix. Without a known type the column name goes out bare
59
+ # (the generator defaults to string).
60
+ def column_migration_hint(klass, field, types)
61
+ type = types.is_a?(Hash) ? types[field.to_sym] : types
62
+ column = [field, type].compact.join(":")
63
+ " Add it with: bin/rails generate migration " \
64
+ "Add#{field.to_s.camelize}To#{klass.table_name.to_s.camelize} #{column}"
65
+ end
66
+
48
67
  # True when the class's table can actually be inspected. Connection
49
68
  # errors (ConnectionNotEstablished, NoDatabaseError, adapter errors)
50
69
  # all inherit from ActiveRecord::ActiveRecordError.
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.23.0".freeze
2
+ VERSION = "1.24.0".freeze
3
3
  end
@@ -3,9 +3,86 @@ require "active_support/deprecation"
3
3
  require "concerns_on_rails/version"
4
4
 
5
5
  module ConcernsOnRails
6
- module Models; end
7
- module Controllers; end
8
- module Support; end
6
+ # Raised (as a LoadError subclass, so a bare `rescue LoadError` still works)
7
+ # when a concern needs a third-party gem that isn't available. friendly_id
8
+ # and acts_as_list load lazily with the concern that uses them (Sluggable /
9
+ # Sortable), so hosts that never touch those concerns never load them.
10
+ class MissingDependency < LoadError; end
11
+
12
+ # Everything below is autoloaded on first constant reference instead of
13
+ # eagerly required: an app that uses two concerns loads two files (plus
14
+ # their support helpers), not all forty — and friendly_id / acts_as_list
15
+ # stay unloaded unless Sluggable / Sortable is actually included.
16
+ # Each concern file requires the Support helpers it uses, so a direct
17
+ # `require "concerns_on_rails/models/sluggable"` keeps working too.
18
+
19
+ module Models
20
+ autoload :Sluggable, "concerns_on_rails/models/sluggable"
21
+ autoload :Sortable, "concerns_on_rails/models/sortable"
22
+ autoload :Publishable, "concerns_on_rails/models/publishable"
23
+ autoload :SoftDeletable, "concerns_on_rails/models/soft_deletable"
24
+ autoload :Hashable, "concerns_on_rails/models/hashable"
25
+ autoload :Schedulable, "concerns_on_rails/models/schedulable"
26
+ autoload :Expirable, "concerns_on_rails/models/expirable"
27
+ autoload :Normalizable, "concerns_on_rails/models/normalizable"
28
+ autoload :Searchable, "concerns_on_rails/models/searchable"
29
+ autoload :Activatable, "concerns_on_rails/models/activatable"
30
+ autoload :Tokenizable, "concerns_on_rails/models/tokenizable"
31
+ autoload :Stateable, "concerns_on_rails/models/stateable"
32
+ autoload :Addressable, "concerns_on_rails/models/addressable"
33
+ autoload :Sequenceable, "concerns_on_rails/models/sequenceable"
34
+ autoload :Taggable, "concerns_on_rails/models/taggable"
35
+ autoload :Sanitizable, "concerns_on_rails/models/sanitizable"
36
+ autoload :Maskable, "concerns_on_rails/models/maskable"
37
+ autoload :Monetizable, "concerns_on_rails/models/monetizable"
38
+ autoload :Auditable, "concerns_on_rails/models/auditable"
39
+ autoload :Lockable, "concerns_on_rails/models/lockable"
40
+ autoload :Aliasable, "concerns_on_rails/models/aliasable"
41
+ autoload :Storable, "concerns_on_rails/models/storable"
42
+ autoload :CounterCacheable, "concerns_on_rails/models/counter_cacheable"
43
+ autoload :Encryptable, "concerns_on_rails/models/encryptable"
44
+ autoload :Anonymizable, "concerns_on_rails/models/anonymizable"
45
+ autoload :Duplicable, "concerns_on_rails/models/duplicable"
46
+ end
47
+
48
+ module Controllers
49
+ autoload :Paginatable, "concerns_on_rails/controllers/paginatable"
50
+ autoload :Filterable, "concerns_on_rails/controllers/filterable"
51
+ autoload :Sortable, "concerns_on_rails/controllers/sortable"
52
+ autoload :Respondable, "concerns_on_rails/controllers/respondable"
53
+ autoload :ErrorHandleable, "concerns_on_rails/controllers/error_handleable"
54
+ autoload :Includable, "concerns_on_rails/controllers/includable"
55
+ autoload :SecureHeadable, "concerns_on_rails/controllers/secure_headable"
56
+ autoload :Localizable, "concerns_on_rails/controllers/localizable"
57
+ autoload :Authorizable, "concerns_on_rails/controllers/authorizable"
58
+ autoload :Throttleable, "concerns_on_rails/controllers/throttleable"
59
+ autoload :Timezoneable, "concerns_on_rails/controllers/timezoneable"
60
+ autoload :Idempotentable, "concerns_on_rails/controllers/idempotentable"
61
+ autoload :WebhookVerifiable, "concerns_on_rails/controllers/webhook_verifiable"
62
+ autoload :CursorPaginatable, "concerns_on_rails/controllers/cursor_paginatable"
63
+ autoload :Deprecatable, "concerns_on_rails/controllers/deprecatable"
64
+ autoload :Cacheable, "concerns_on_rails/controllers/cacheable"
65
+ end
66
+
67
+ module Support
68
+ autoload :ColumnGuard, "concerns_on_rails/support/column_guard"
69
+ autoload :ScalarParam, "concerns_on_rails/support/scalar_param"
70
+ autoload :UniqueRetry, "concerns_on_rails/support/unique_retry"
71
+ autoload :ErrorEnvelope, "concerns_on_rails/support/error_envelope"
72
+ autoload :FilterParameterRegistry, "concerns_on_rails/support/filter_parameter_registry"
73
+ autoload :RandomValue, "concerns_on_rails/support/random_value"
74
+ autoload :AddressData, "concerns_on_rails/support/address_data"
75
+ autoload :SequenceCalculator, "concerns_on_rails/support/sequence_calculator"
76
+ autoload :HtmlSanitizers, "concerns_on_rails/support/html_sanitizers"
77
+ autoload :Masker, "concerns_on_rails/support/masker"
78
+ autoload :Money, "concerns_on_rails/support/money"
79
+ autoload :Encryptor, "concerns_on_rails/support/encryptor"
80
+ end
81
+
82
+ # Encryption config + error types (Support::Encryptor requires it itself)
83
+ autoload :Encryption, "concerns_on_rails/encryption"
84
+ # Gem-wide configuration object behind ConcernsOnRails.setup
85
+ autoload :Configuration, "concerns_on_rails/configuration"
9
86
 
10
87
  # Guards the lazy singletons below: the first encrypted attribute read (or
11
88
  # deprecation warning) can happen on any request thread, and an unsynchronized
@@ -26,6 +103,20 @@ module ConcernsOnRails
26
103
  end
27
104
  end
28
105
 
106
+ # Gem-wide configuration (see Configuration), set from an initializer:
107
+ #
108
+ # ConcernsOnRails.setup do |config|
109
+ # config.cache_store = -> { Rails.cache }
110
+ # end
111
+ def self.config
112
+ @config || @config_mutex.synchronize { @config ||= Configuration.new }
113
+ end
114
+
115
+ def self.setup
116
+ yield config if block_given?
117
+ config
118
+ end
119
+
29
120
  # Gem-wide encryption configuration backing Models::Encryptable. Memoized like
30
121
  # `deprecator`; the host app supplies the key (see ConcernsOnRails::Encryption):
31
122
  #
@@ -54,70 +145,7 @@ module ConcernsOnRails
54
145
  end
55
146
  end
56
147
 
57
- # Encryption config + error types (loaded before the support codec that uses them)
58
- require "concerns_on_rails/encryption"
59
-
60
- # Shared internal helpers (must load before the concerns that use them)
61
- require "concerns_on_rails/support/column_guard"
62
- require "concerns_on_rails/support/scalar_param"
63
- require "concerns_on_rails/support/unique_retry"
64
- require "concerns_on_rails/support/error_envelope"
65
- require "concerns_on_rails/support/filter_parameter_registry"
66
- require "concerns_on_rails/support/random_value"
67
- require "concerns_on_rails/support/address_data"
68
- require "concerns_on_rails/support/sequence_calculator"
69
- require "concerns_on_rails/support/html_sanitizers"
70
- require "concerns_on_rails/support/masker"
71
- require "concerns_on_rails/support/money"
72
- require "concerns_on_rails/support/encryptor"
73
-
74
- # Model concerns
75
- require "concerns_on_rails/models/sluggable"
76
- require "concerns_on_rails/models/sortable"
77
- require "concerns_on_rails/models/publishable"
78
- require "concerns_on_rails/models/soft_deletable"
79
- require "concerns_on_rails/models/hashable"
80
- require "concerns_on_rails/models/schedulable"
81
- require "concerns_on_rails/models/expirable"
82
- require "concerns_on_rails/models/normalizable"
83
- require "concerns_on_rails/models/searchable"
84
- require "concerns_on_rails/models/activatable"
85
- require "concerns_on_rails/models/tokenizable"
86
- require "concerns_on_rails/models/stateable"
87
- require "concerns_on_rails/models/addressable"
88
- require "concerns_on_rails/models/sequenceable"
89
- require "concerns_on_rails/models/taggable"
90
- require "concerns_on_rails/models/sanitizable"
91
- require "concerns_on_rails/models/maskable"
92
- require "concerns_on_rails/models/monetizable"
93
- require "concerns_on_rails/models/auditable"
94
- require "concerns_on_rails/models/lockable"
95
- require "concerns_on_rails/models/aliasable"
96
- require "concerns_on_rails/models/storable"
97
- require "concerns_on_rails/models/counter_cacheable"
98
- require "concerns_on_rails/models/encryptable"
99
- require "concerns_on_rails/models/anonymizable"
100
- require "concerns_on_rails/models/duplicable"
101
-
102
- # Controller concerns
103
- require "concerns_on_rails/controllers/paginatable"
104
- require "concerns_on_rails/controllers/filterable"
105
- require "concerns_on_rails/controllers/sortable"
106
- require "concerns_on_rails/controllers/respondable"
107
- require "concerns_on_rails/controllers/error_handleable"
108
- require "concerns_on_rails/controllers/includable"
109
- require "concerns_on_rails/controllers/secure_headable"
110
- require "concerns_on_rails/controllers/localizable"
111
- require "concerns_on_rails/controllers/authorizable"
112
- require "concerns_on_rails/controllers/throttleable"
113
- require "concerns_on_rails/controllers/timezoneable"
114
- require "concerns_on_rails/controllers/idempotentable"
115
- require "concerns_on_rails/controllers/webhook_verifiable"
116
- require "concerns_on_rails/controllers/cursor_paginatable"
117
- require "concerns_on_rails/controllers/deprecatable"
118
- require "concerns_on_rails/controllers/cacheable"
119
-
120
- # Backwards compatibility (top-level aliases for pre-1.6 module paths)
148
+ # Backwards compatibility (lazy top-level aliases for pre-1.6 module paths)
121
149
  require "concerns_on_rails/legacy_aliases"
122
150
 
123
151
  # Boot-time integration (filter_parameters registration), Rails apps only
metadata CHANGED
@@ -1,17 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.23.0
4
+ version: 1.24.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-07-27 00:00:00.000000000 Z
11
+ date: 2026-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rails
14
+ name: actionpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activerecord
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '9'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '5.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '9'
53
+ - !ruby/object:Gem::Dependency
54
+ name: activesupport
15
55
  requirement: !ruby/object:Gem::Requirement
16
56
  requirements:
17
57
  - - ">="
@@ -34,16 +74,22 @@ dependencies:
34
74
  name: acts_as_list
35
75
  requirement: !ruby/object:Gem::Requirement
36
76
  requirements:
37
- - - "~>"
77
+ - - ">="
38
78
  - !ruby/object:Gem::Version
39
79
  version: 0.7.5
80
+ - - "<"
81
+ - !ruby/object:Gem::Version
82
+ version: '2'
40
83
  type: :runtime
41
84
  prerelease: false
42
85
  version_requirements: !ruby/object:Gem::Requirement
43
86
  requirements:
44
- - - "~>"
87
+ - - ">="
45
88
  - !ruby/object:Gem::Version
46
89
  version: 0.7.5
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '2'
47
93
  - !ruby/object:Gem::Dependency
48
94
  name: friendly_id
49
95
  requirement: !ruby/object:Gem::Requirement
@@ -71,6 +117,7 @@ files:
71
117
  - LICENSE.txt
72
118
  - README.md
73
119
  - lib/concerns_on_rails.rb
120
+ - lib/concerns_on_rails/configuration.rb
74
121
  - lib/concerns_on_rails/controllers/authorizable.rb
75
122
  - lib/concerns_on_rails/controllers/cacheable.rb
76
123
  - lib/concerns_on_rails/controllers/cursor_paginatable.rb