concerns_on_rails 1.22.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +91 -7
  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 -27
  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/anonymizable.rb +218 -0
  11. data/lib/concerns_on_rails/models/auditable.rb +1 -1
  12. data/lib/concerns_on_rails/models/counter_cacheable.rb +1 -1
  13. data/lib/concerns_on_rails/models/duplicable.rb +207 -0
  14. data/lib/concerns_on_rails/models/encryptable.rb +2 -2
  15. data/lib/concerns_on_rails/models/expirable.rb +1 -1
  16. data/lib/concerns_on_rails/models/hashable.rb +2 -1
  17. data/lib/concerns_on_rails/models/lockable.rb +2 -1
  18. data/lib/concerns_on_rails/models/monetizable.rb +1 -1
  19. data/lib/concerns_on_rails/models/publishable.rb +1 -1
  20. data/lib/concerns_on_rails/models/schedulable.rb +1 -1
  21. data/lib/concerns_on_rails/models/sequenceable.rb +3 -3
  22. data/lib/concerns_on_rails/models/sluggable.rb +12 -2
  23. data/lib/concerns_on_rails/models/soft_deletable.rb +1 -1
  24. data/lib/concerns_on_rails/models/sortable.rb +11 -2
  25. data/lib/concerns_on_rails/models/stateable.rb +1 -1
  26. data/lib/concerns_on_rails/models/storable.rb +1 -1
  27. data/lib/concerns_on_rails/models/taggable.rb +1 -1
  28. data/lib/concerns_on_rails/models/tokenizable.rb +1 -1
  29. data/lib/concerns_on_rails/support/column_guard.rb +23 -4
  30. data/lib/concerns_on_rails/version.rb +1 -1
  31. data/lib/concerns_on_rails.rb +95 -65
  32. metadata +54 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7414e40bd04fc80a1b87b4c27b908ff169c5a9c2f9c50e66d8ab8d75198b9f40
4
- data.tar.gz: 6f458ab30c720e633cd50f34515b7941ec9b6b8a8ef208f3beed909860ac1d9f
3
+ metadata.gz: a5361fdba6417a0a46213e0e8e41f8ced13d00f76cb17f102404d1d794a98a39
4
+ data.tar.gz: 0fab62907022084c9b54d1cca52f8b4eeefbecced0a4bc905e4b3a8a55f8f582
5
5
  SHA512:
6
- metadata.gz: dbbcf38ca1645b801c05e8949f869e587752607bb0d679c279eeac2bc8904c446d7da7ee865498b8578ca5d1d8212477b60cb840d0e3c53e1a1d9593065805af
7
- data.tar.gz: 524c14d7bd3e5824c0d266a3ab84e13cd21d622d7637dc4bde38225ff40e88d24559eb36055c16eaa60edb53b99c025c96effa4a458e1701b36a0826bd8a12b5
6
+ metadata.gz: 5654215000d983a63260507d6741090add3b7861e200e81ebd180ad7d7de61891c0646c49b666d4b01b179b51ae88d1da73386ffb35bc1cbb24328d8f3238c99
7
+ data.tar.gz: 436b80fa129a4a3922a0a6b7ba4945f90205f1071a4d190875fa26e87458bb39ba483b0f682bff0c64cd3fb09fda79ec15cec776f22b3d085af03d14fe5a2275
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
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
+
15
+ ## 1.23.0 (2026-07-26)
16
+
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.
18
+
19
+ ### Added
20
+ - **Models::Anonymizable**: declarative right-to-erasure ("GDPR-lite") for personal data — the fourth member of the sensitive-data suite (Maskable masks display, Sanitizable strips HTML, Encryptable protects at rest; Anonymizable DESTROYS). `anonymizable *fields, with:` declares an erasure strategy per field group (repeatable; rules merge): presets `:nullify`, `:redact`, `:hash` (SHA-256 — deterministic pseudonymization, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive erasure), `:random_hex`, or a callable (`->(value)` / `->(value, record)`); presets pass nil through untouched. `anonymize!` runs `before_anonymize` + ONE `update_columns` UPDATE + `after_anonymize` in a single transaction — deliberately skipping validations (erasure must not be blocked by a presence check) and callbacks (nothing may copy the old values elsewhere; Auditable's capture hook is the canonical example) — then reloads. Values serialize through the model's attribute types, so a field that is also `encryptable` stores a fresh ciphertext envelope of the anonymized value, never plaintext (spec-verified). When an erased field is also `auditable_by`, the audit column (which holds its plaintext history) is cleared in the same UPDATE (`clear_audit_trail: false` opts out). `stamp:` column (default `:anonymized_at`; `false` opts out) powers `anonymized?`, the `anonymized`/`not_anonymized` scopes (affixable via `prefix:`/`suffix:`), and the idempotent batch `anonymize_all!` (Integer count, skips stamped records — the 1.22 batch contract). Macro-time `ArgumentError` validation for fields, presets, callables, and columns. Zero new dependencies.
21
+ - **Models::Duplicable**: concern-aware deep copy ("clone this invoice/template"). A bare AR `dup` copies identity-bearing columns — slug, API token, invoice number, audit trail, even `created_at` (which AR preserves on save when present) — so naive copies collide with unique indexes or lie about their history. `duplicate` (unsaved) / `duplicate!(overrides)` (saved, one transaction, autosaved children) blank exactly those columns automatically: timestamps, Sluggable's slug, Tokenizable/Hashable columns, Sequenceable sequence + `into:` columns, Auditable's trail (the copy inherits no history; its own creation is then audited normally), SoftDeletable's timestamp (a copy of trash is live), and Lockable's attempts (0) / locked_at (nil) — each concern regenerates fresh values on the copy's save. Business state (Publishable/Stateable/…) is deliberately NOT auto-reset — list it in `reset:`. `duplicable_by associations:, reset:, suffix:` (optional — bare include works): the association allow-list is validated at macro time (declared-before, the CounterCacheable convention); `has_many`/`has_one` children deep-copy — a child whose class also includes Duplicable copies via ITS OWN rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the same records; `belongs_to` and `has_many :through` are rejected with explanations. `suffix: { title: " (copy)" }` appends to present values; `on_duplicate(copy)` is the override hook. Zero new dependencies.
22
+
23
+ ### Internal
24
+ - Top-level aliases `ConcernsOnRails::Anonymizable` / `ConcernsOnRails::Duplicable` registered alongside the existing set; docs site pages + registry entries added for both concerns.
25
+
3
26
  ## 1.22.0 (2026-07-26)
4
27
 
5
28
  A fixes-and-optimizations release driven by a full-library review of all 40 concerns: one data-loss bug, three request-crashing 500s verified through real ActionController dispatch, security headers restored on rescued error responses, a fail-open authorization path closed, and the gem's largest performance defect (unmemoized PBKDF2 per encrypted-value access) eliminated. 1031 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
 
@@ -66,6 +66,8 @@ Article.published.without_deleted.find("hello-world")
66
66
  | [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
67
67
  | [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
68
68
  | [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups |
69
+ | [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies |
70
+ | [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") |
69
71
 
70
72
  ### 🎮 Controller concerns
71
73
 
@@ -92,9 +94,9 @@ Article.published.without_deleted.find("hello-world")
92
94
 
93
95
  ## ✨ Why this gem?
94
96
 
95
- - **Twenty-four model concerns + sixteen controller concerns**, all production-ready
97
+ - **Twenty-six model concerns + sixteen controller concerns**, all production-ready
96
98
  - **One include, one macro** — no boilerplate, no glue code
97
- - **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
98
100
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
99
101
  - **Composable** — concerns are independent; mix and match per model
100
102
 
@@ -105,7 +107,7 @@ Article.published.without_deleted.find("hello-world")
105
107
  Add to your application's `Gemfile`:
106
108
 
107
109
  ```ruby
108
- gem "concerns_on_rails", "~> 1.22"
110
+ gem "concerns_on_rails", "~> 1.24"
109
111
  ```
110
112
 
111
113
  Or pull the latest from GitHub:
@@ -120,6 +122,27 @@ Then run:
120
122
  bundle install
121
123
  ```
122
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
+
123
146
  ---
124
147
 
125
148
  ## 🧪 Compatibility
@@ -1167,6 +1190,66 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1167
1190
 
1168
1191
  ---
1169
1192
 
1193
+ ## 🕵️ Anonymizable
1194
+
1195
+ Declarative right-to-erasure ("GDPR-lite"): each personal-data field gets an erasure strategy, and `anonymize!` rewrites them all in **one UPDATE** while stamping `anonymized_at`. Completes the sensitive-data suite — Maskable masks *display*, Sanitizable strips *HTML*, Encryptable protects *at rest*; Anonymizable **destroys**.
1196
+
1197
+ ```ruby
1198
+ class User < ApplicationRecord
1199
+ include ConcernsOnRails::Models::Anonymizable
1200
+
1201
+ anonymizable :email, with: :email # unique fake address
1202
+ anonymizable :first_name, :last_name, with: :redact
1203
+ anonymizable :ssn, with: :nullify
1204
+ anonymizable :bio, with: ->(value) { value && "removed by user request" }
1205
+ end
1206
+
1207
+ user.anonymize! # hooks + single UPDATE + stamp, in one transaction
1208
+ user.anonymized? # => true
1209
+ User.not_anonymized # scope (and .anonymized)
1210
+ User.where(...).anonymize_all! # batch; returns the count, skips stamped records
1211
+ ```
1212
+
1213
+ **Strategies** (`with:`): `:nullify`, `:redact` (`"[REDACTED]"`), `:hash` (SHA-256 — deterministic *pseudonymization*, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive), `:random_hex`, or a callable (`->(value)` / `->(value, record)`). Presets pass `nil` through untouched.
1214
+
1215
+ **Options** (repeatable; field rules merge): `stamp:` (default `:anonymized_at`; `false` disables stamping + scopes), `clear_audit_trail:` (default `true` — when an erased field is also `auditable_by`, the trail holding its plaintext history is cleared in the same UPDATE), `prefix:`/`suffix:` (scope names).
1216
+
1217
+ **Notes**
1218
+ - Deliberately `update_columns`: erasure is never blocked by validations and never runs callbacks that could copy old values elsewhere. Values still serialize through the attribute types, so an `encryptable` field stores a fresh ciphertext envelope — never plaintext.
1219
+ - `before_anonymize`/`after_anonymize` hooks run inside the transaction; the record reloads afterwards (erasure is terminal for the instance).
1220
+ - `:hash` is pseudonymization — use `:nullify`/`:random_hex` for true erasure. Backups/replicas/logs are out of scope.
1221
+
1222
+ ---
1223
+
1224
+ ## 🧬 Duplicable
1225
+
1226
+ Concern-aware deep copy — the "clone this invoice / duplicate this template" feature. A bare `dup` copies identity-bearing columns (slug, token, invoice number, audit trail, even `created_at`, which AR preserves on save), so naive copies collide with unique indexes. Duplicable blanks exactly those columns and lets each sibling concern regenerate fresh values on save.
1227
+
1228
+ ```ruby
1229
+ class Invoice < ApplicationRecord
1230
+ include ConcernsOnRails::Models::Duplicable
1231
+
1232
+ has_many :line_items
1233
+ duplicable_by associations: %i[line_items],
1234
+ reset: %i[issued_at],
1235
+ suffix: { title: " (copy)" }
1236
+ end
1237
+
1238
+ copy = invoice.duplicate # unsaved deep copy
1239
+ copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
1240
+ ```
1241
+
1242
+ **Auto-reset identity columns** (no configuration): `created_at`/`updated_at`, Sluggable slug, Tokenizable/Hashable tokens, Sequenceable sequence + `into:` columns, Auditable trail, SoftDeletable timestamp, Lockable attempts/locked_at. Business state (Publishable, Stateable, …) is a judgment call — list it in `reset:`.
1243
+
1244
+ **Associations** (`associations:` allow-list, declared before the macro, validated at macro time): `has_many`/`has_one` children are deep-copied — a child that also includes Duplicable copies via **its own** rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the *same* records; `belongs_to` and `has_many :through` are rejected with an explanation.
1245
+
1246
+ **Notes**
1247
+ - The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
1248
+ - Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
1249
+ - Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
1250
+
1251
+ ---
1252
+
1170
1253
  # 🎮 Controller Concerns
1171
1254
 
1172
1255
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1537,7 +1620,7 @@ Fixed-window counter: the key embeds a floored time bucket (`epoch / period`) so
1537
1620
 
1538
1621
  **Notes**
1539
1622
  - The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
1540
- - 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.
1541
1624
  - When `Respondable` is included, the 429 body delegates to `render_error` (`code: "rate_limited"`).
1542
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).
1543
1626
 
@@ -1586,7 +1669,7 @@ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) →
1586
1669
 
1587
1670
  **Notes**
1588
1671
  - Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
1589
- - 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 } }`).
1590
1673
  - When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
1591
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.
1592
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.
@@ -1739,6 +1822,7 @@ Both forms reference the same module, so you can freely mix them.
1739
1822
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1740
1823
  | Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
1741
1824
  | Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
1825
+ | Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
1742
1826
 
1743
1827
  `Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
1744
1828
 
@@ -1750,7 +1834,7 @@ Both forms reference the same module, so you can freely mix them.
1750
1834
  bundle install # install dev dependencies
1751
1835
  bundle exec rspec # run the test suite
1752
1836
  gem build concerns_on_rails.gemspec # build the gem
1753
- gem install ./concerns_on_rails-1.22.0.gem # install locally
1837
+ gem install ./concerns_on_rails-1.24.0.gem # install locally
1754
1838
  ```
1755
1839
 
1756
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,29 +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
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
29
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
 
@@ -0,0 +1,218 @@
1
+ require "active_support/concern"
2
+ require "concerns_on_rails/support/column_guard"
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module ConcernsOnRails
7
+ module Models
8
+ # Declarative right-to-erasure ("GDPR-lite") for personal data. The fourth
9
+ # member of the sensitive-data suite: Maskable masks *display*, Sanitizable
10
+ # strips *HTML*, Encryptable protects *at rest* — Anonymizable DESTROYS.
11
+ #
12
+ # class User < ApplicationRecord
13
+ # include ConcernsOnRails::Models::Anonymizable
14
+ #
15
+ # anonymizable :email, with: :email # unique fake address
16
+ # anonymizable :first_name, :last_name, with: :redact
17
+ # anonymizable :ssn, with: :nullify
18
+ # anonymizable :bio, with: ->(value) { value && "removed by user request" }
19
+ # end
20
+ #
21
+ # user.anonymize! # one UPDATE: strategies + anonymized_at stamp
22
+ # user.anonymized? # => true
23
+ # User.not_anonymized # scope (and .anonymized)
24
+ # User.where(...).anonymize_all! # batch; returns the count
25
+ #
26
+ # HOW IT WRITES — deliberately update_columns (single UPDATE, no
27
+ # validations, no callbacks): erasure must not be blocked by a presence/
28
+ # format validation, and must not run callbacks that would copy the OLD
29
+ # values somewhere new (Auditable's capture hook is the canonical example).
30
+ # update_columns serializes each value through the model's attribute types
31
+ # — Encryptable's custom type included — so a field that is also
32
+ # `encryptable` stores a fresh ciphertext envelope of the anonymized
33
+ # value, never plaintext. The record is reloaded afterwards so in-memory
34
+ # readers see the anonymized values through the types.
35
+ #
36
+ # Strategy presets (`with:`):
37
+ # :nullify — nil
38
+ # :redact — "[REDACTED]"
39
+ # :hash — SHA-256 hex of the value (deterministic pseudonymization:
40
+ # the same input digests the same, so datasets keyed on the
41
+ # value still join — NOT full anonymization)
42
+ # :email — "anon-<random-hex>@anonymized.invalid" (random + unique,
43
+ # so NOT NULL / unique-index email columns survive erasure;
44
+ # .invalid is an RFC 2606 reserved TLD — it can never send)
45
+ # :random_hex — 32 random hex chars (unique tokens/usernames)
46
+ # a callable — ->(value) { ... } or ->(value, record) { ... }; nil-in
47
+ # nil-out is the preset convention, custom callables choose
48
+ #
49
+ # Notes:
50
+ # * The stamp column (default :anonymized_at, `stamp: false` to opt out)
51
+ # is what makes `anonymized?`, the scopes, and anonymize_all!'s
52
+ # idempotency work — add it (a datetime) unless you truly can't.
53
+ # * Auditable interaction: if any anonymized field is also audited, the
54
+ # trail already holds historical plaintext, so anonymize! clears the
55
+ # audit column in the SAME update (opt out per-macro with
56
+ # `clear_audit_trail: false`). The trail is one column — clearing is
57
+ # all-or-nothing.
58
+ # * Encryptable interaction: works transparently (see HOW IT WRITES).
59
+ # * Erasure is terminal: unsaved changes on the instance are discarded by
60
+ # the post-write reload.
61
+ module Anonymizable
62
+ extend ActiveSupport::Concern
63
+
64
+ LABEL = "ConcernsOnRails::Models::Anonymizable".freeze
65
+ DEFAULT_STAMP = :anonymized_at
66
+ # Distinguishes "option not passed" from an explicit value, so repeat
67
+ # macro calls merge fields without silently resetting earlier options.
68
+ UNSET = Object.new
69
+
70
+ PRESETS = {
71
+ nullify: ->(_value) {},
72
+ redact: ->(value) { value.nil? ? nil : "[REDACTED]" },
73
+ hash: ->(value) { value.nil? ? nil : Digest::SHA256.hexdigest(value.to_s) },
74
+ email: ->(value) { value.nil? ? nil : "anon-#{SecureRandom.hex(10)}@anonymized.invalid" },
75
+ random_hex: ->(value) { value.nil? ? nil : SecureRandom.hex(16) }
76
+ }.freeze
77
+
78
+ included do
79
+ class_attribute :anonymizable_rules, instance_accessor: false, default: {}
80
+ class_attribute :anonymizable_stamp, instance_accessor: false, default: DEFAULT_STAMP
81
+ class_attribute :anonymizable_clear_audit, instance_accessor: false, default: true
82
+ class_attribute :anonymizable_scopes_defined, instance_accessor: false, default: false
83
+ end
84
+
85
+ module ClassMethods
86
+ include ConcernsOnRails::Support::ColumnGuard
87
+
88
+ # Declare fields and their erasure strategy. Repeatable — field rules
89
+ # merge across calls; stamp:/clear_audit_trail:/prefix:/suffix: apply
90
+ # only when explicitly passed (last explicit value wins).
91
+ def anonymizable(*fields, with:, stamp: UNSET, clear_audit_trail: UNSET, prefix: nil, suffix: nil)
92
+ raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
93
+
94
+ strategy = anonymizable_resolve_strategy(with)
95
+ anonymizable_apply_options(stamp, clear_audit_trail)
96
+
97
+ ensure_columns!(LABEL, fields)
98
+ ensure_columns!(LABEL, anonymizable_stamp) if anonymizable_stamp
99
+ self.anonymizable_rules = anonymizable_rules.merge(fields.to_h { |f| [f.to_sym, strategy] })
100
+
101
+ anonymizable_define_scopes(prefix, suffix)
102
+ end
103
+
104
+ # Anonymize every matching record that isn't already stamped, in one
105
+ # transaction. Returns the Integer count of records anonymized (the
106
+ # 1.22 batch contract). Without a stamp column every record matches.
107
+ def anonymize_all!
108
+ transaction do
109
+ all.to_a.count do |record|
110
+ next false if record.anonymized?
111
+
112
+ record.anonymize!
113
+ true
114
+ end
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def anonymizable_apply_options(stamp, clear_audit_trail)
121
+ # `.presence` (not `&.`): `stamp: false` must resolve to nil, and
122
+ # false&.to_sym would raise.
123
+ self.anonymizable_stamp = stamp.presence && stamp.to_sym unless stamp.equal?(UNSET)
124
+ return if clear_audit_trail.equal?(UNSET)
125
+
126
+ self.anonymizable_clear_audit = clear_audit_trail ? true : false
127
+ end
128
+
129
+ def anonymizable_resolve_strategy(with)
130
+ case with
131
+ when Symbol
132
+ PRESETS.fetch(with) do
133
+ raise ArgumentError, "#{LABEL}: unknown preset '#{with}'. Valid presets: #{PRESETS.keys.join(', ')}"
134
+ end
135
+ else
136
+ raise ArgumentError, "#{LABEL}: :with must be a preset symbol or a callable, got #{with.class}" unless with.respond_to?(:call)
137
+
138
+ with
139
+ end
140
+ end
141
+
142
+ # Scopes read the class attribute lazily, so later stamp changes take
143
+ # effect; defined once (affixes come from the first defining call).
144
+ def anonymizable_define_scopes(prefix, suffix)
145
+ return if anonymizable_scopes_defined || anonymizable_stamp.nil?
146
+
147
+ self.anonymizable_scopes_defined = true
148
+ affixed = ->(base) { [prefix, base, suffix].compact.join("_") }
149
+ scope affixed.call("anonymized"), -> { where.not(anonymizable_stamp => nil) }
150
+ scope affixed.call("not_anonymized"), -> { where(anonymizable_stamp => nil) }
151
+ end
152
+ end
153
+
154
+ # Lifecycle hooks — override in the model. Run inside the anonymize!
155
+ # transaction, so a raising hook rolls the erasure back.
156
+ def before_anonymize; end
157
+ def after_anonymize; end
158
+
159
+ # Erase the configured fields in a single UPDATE (see the module docs for
160
+ # why validations and callbacks are deliberately skipped). Returns true.
161
+ def anonymize!
162
+ raise ArgumentError, "#{LABEL}: anonymize! cannot be called on a new record" if new_record?
163
+
164
+ payload = anonymizable_payload
165
+ transaction do
166
+ before_anonymize
167
+ update_columns(payload)
168
+ after_anonymize
169
+ end
170
+ # update_columns leaves DB-serialized values (e.g. ciphertext) in the
171
+ # in-memory attributes; reload so readers decode through the types.
172
+ reload
173
+ true
174
+ end
175
+
176
+ # True when the stamp column is set; always false with `stamp: false`
177
+ # (there is nothing to observe).
178
+ def anonymized?
179
+ stamp = self.class.anonymizable_stamp
180
+ stamp ? self[stamp].present? : false
181
+ end
182
+
183
+ private
184
+
185
+ # { column => value }: strategy output cast through the attribute's type,
186
+ # plus the stamp and — when an anonymized field is also audited — the
187
+ # cleared audit column. update_columns serializes each value through the
188
+ # model's attribute types (verified: Encryptable's custom type included),
189
+ # so an encrypted field stores a fresh ciphertext envelope — passing a
190
+ # pre-serialized value here would double-encrypt.
191
+ def anonymizable_payload
192
+ payload = {}
193
+ self.class.anonymizable_rules.each do |field, strategy|
194
+ value = anonymizable_apply_strategy(strategy, public_send(field))
195
+ payload[field] = self.class.type_for_attribute(field.to_s).cast(value)
196
+ end
197
+ stamp = self.class.anonymizable_stamp
198
+ payload[stamp] = Time.zone.now if stamp
199
+ payload[self.class.auditable_into] = nil if anonymizable_clear_audit_column?
200
+ payload
201
+ end
202
+
203
+ def anonymizable_apply_strategy(strategy, value)
204
+ strategy.arity == 1 ? strategy.call(value) : strategy.call(value, self)
205
+ end
206
+
207
+ # The audit trail holds historical plaintext of tracked fields; when any
208
+ # of them is being erased, the trail must go too (see module docs).
209
+ def anonymizable_clear_audit_column?
210
+ return false unless self.class.anonymizable_clear_audit
211
+ return false unless self.class.respond_to?(:auditable_fields)
212
+
213
+ tracked = Array(self.class.auditable_fields).map(&:to_sym)
214
+ self.class.anonymizable_rules.keys.intersect?(tracked)
215
+ end
216
+ end
217
+ end
218
+ end
@@ -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)