concerns_on_rails 1.22.0 → 1.23.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: 7414e40bd04fc80a1b87b4c27b908ff169c5a9c2f9c50e66d8ab8d75198b9f40
4
- data.tar.gz: 6f458ab30c720e633cd50f34515b7941ec9b6b8a8ef208f3beed909860ac1d9f
3
+ metadata.gz: ba3e5dc1bc6af51ec2bd4e29946973c5fc68736d4c1994fdbd472874f965c4bc
4
+ data.tar.gz: 16b2372635abe33484efabcae5407d1debe5a0627df9df0496a04e930c46ec65
5
5
  SHA512:
6
- metadata.gz: dbbcf38ca1645b801c05e8949f869e587752607bb0d679c279eeac2bc8904c446d7da7ee865498b8578ca5d1d8212477b60cb840d0e3c53e1a1d9593065805af
7
- data.tar.gz: 524c14d7bd3e5824c0d266a3ab84e13cd21d622d7637dc4bde38225ff40e88d24559eb36055c16eaa60edb53b99c025c96effa4a458e1701b36a0826bd8a12b5
6
+ metadata.gz: 8a159b0026bca1ebc72dbc11c45e875caaa19ad2ec0615ee658abd5b3e8ab53e0bb309d816a5371ddba6bad5c609b82a76228e66d678d3346d624021634eddf5
7
+ data.tar.gz: da89fb098f9e17fd9ae49662efdb027efb64958007bf790ee895c0faf39a1c9f2cb76d2e95a4b0b3b075bcb404fe926c36d970f78ca152bdd287be81e7a3a991
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.23.0 (2026-07-26)
4
+
5
+ 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.
6
+
7
+ ### Added
8
+ - **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.
9
+ - **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.
10
+
11
+ ### Internal
12
+ - Top-level aliases `ConcernsOnRails::Anonymizable` / `ConcernsOnRails::Duplicable` registered alongside the existing set; docs site pages + registry entries added for both concerns.
13
+
3
14
  ## 1.22.0 (2026-07-26)
4
15
 
5
16
  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
@@ -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,7 +94,7 @@ 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
99
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
98
100
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -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.23"
109
111
  ```
110
112
 
111
113
  Or pull the latest from GitHub:
@@ -1167,6 +1169,66 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1167
1169
 
1168
1170
  ---
1169
1171
 
1172
+ ## 🕵️ Anonymizable
1173
+
1174
+ 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**.
1175
+
1176
+ ```ruby
1177
+ class User < ApplicationRecord
1178
+ include ConcernsOnRails::Models::Anonymizable
1179
+
1180
+ anonymizable :email, with: :email # unique fake address
1181
+ anonymizable :first_name, :last_name, with: :redact
1182
+ anonymizable :ssn, with: :nullify
1183
+ anonymizable :bio, with: ->(value) { value && "removed by user request" }
1184
+ end
1185
+
1186
+ user.anonymize! # hooks + single UPDATE + stamp, in one transaction
1187
+ user.anonymized? # => true
1188
+ User.not_anonymized # scope (and .anonymized)
1189
+ User.where(...).anonymize_all! # batch; returns the count, skips stamped records
1190
+ ```
1191
+
1192
+ **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.
1193
+
1194
+ **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).
1195
+
1196
+ **Notes**
1197
+ - 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.
1198
+ - `before_anonymize`/`after_anonymize` hooks run inside the transaction; the record reloads afterwards (erasure is terminal for the instance).
1199
+ - `:hash` is pseudonymization — use `:nullify`/`:random_hex` for true erasure. Backups/replicas/logs are out of scope.
1200
+
1201
+ ---
1202
+
1203
+ ## 🧬 Duplicable
1204
+
1205
+ 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.
1206
+
1207
+ ```ruby
1208
+ class Invoice < ApplicationRecord
1209
+ include ConcernsOnRails::Models::Duplicable
1210
+
1211
+ has_many :line_items
1212
+ duplicable_by associations: %i[line_items],
1213
+ reset: %i[issued_at],
1214
+ suffix: { title: " (copy)" }
1215
+ end
1216
+
1217
+ copy = invoice.duplicate # unsaved deep copy
1218
+ copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
1219
+ ```
1220
+
1221
+ **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:`.
1222
+
1223
+ **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.
1224
+
1225
+ **Notes**
1226
+ - The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
1227
+ - Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
1228
+ - Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
1229
+
1230
+ ---
1231
+
1170
1232
  # 🎮 Controller Concerns
1171
1233
 
1172
1234
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1739,6 +1801,7 @@ Both forms reference the same module, so you can freely mix them.
1739
1801
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1740
1802
  | 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
1803
  | Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
1804
+ | Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
1742
1805
 
1743
1806
  `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
1807
 
@@ -1750,7 +1813,7 @@ Both forms reference the same module, so you can freely mix them.
1750
1813
  bundle install # install dev dependencies
1751
1814
  bundle exec rspec # run the test suite
1752
1815
  gem build concerns_on_rails.gemspec # build the gem
1753
- gem install ./concerns_on_rails-1.22.0.gem # install locally
1816
+ gem install ./concerns_on_rails-1.23.0.gem # install locally
1754
1817
  ```
1755
1818
 
1756
1819
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -26,4 +26,6 @@ module ConcernsOnRails
26
26
  Storable = Models::Storable
27
27
  CounterCacheable = Models::CounterCacheable
28
28
  Encryptable = Models::Encryptable
29
+ Anonymizable = Models::Anonymizable
30
+ Duplicable = Models::Duplicable
29
31
  end
@@ -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
@@ -0,0 +1,207 @@
1
+ require "active_support/concern"
2
+ require "concerns_on_rails/support/column_guard"
3
+
4
+ module ConcernsOnRails
5
+ module Models
6
+ # Concern-aware deep copy ("clone this invoice/template"). A bare
7
+ # ActiveRecord `dup` copies identity-bearing columns — the slug, the API
8
+ # token, the invoice number, the audit trail, even `created_at` (which AR
9
+ # preserves on save when present) — so naive copies collide with unique
10
+ # indexes or lie about their history. Duplicable knows its sibling
11
+ # concerns and blanks exactly those columns, letting each concern
12
+ # regenerate fresh values on save.
13
+ #
14
+ # class Invoice < ApplicationRecord
15
+ # include ConcernsOnRails::Models::Duplicable
16
+ # include ConcernsOnRails::Models::Sequenceable
17
+ #
18
+ # has_many :line_items
19
+ # sequenceable_by :sequence, into: :number, prefix: "INV-"
20
+ # duplicable_by associations: %i[line_items],
21
+ # reset: %i[issued_at],
22
+ # suffix: { title: " (copy)" }
23
+ # end
24
+ #
25
+ # copy = invoice.duplicate # unsaved deep copy
26
+ # copy = invoice.duplicate!(title: "Q3") # saved, with overrides
27
+ #
28
+ # What gets blanked automatically (identity, not business state):
29
+ # * created_at / updated_at (AR keeps a present created_at on save)
30
+ # * Sluggable slug — regenerated from the source field on save
31
+ # * Tokenizable / Hashable columns — regenerated on create
32
+ # * Sequenceable sequence + into: columns — next number on create
33
+ # * Auditable trail column — a copy inherits no history (its own
34
+ # creation is then audited normally, like any create)
35
+ # * SoftDeletable timestamp — a copy of trash is a live record
36
+ # * Lockable attempts (0) / locked_at (nil) — a copy starts unlocked
37
+ # Business state (Publishable/Stateable/Activatable/...) is a judgment
38
+ # call, so it is NOT auto-reset — list those columns in `reset:`.
39
+ #
40
+ # Associations (`associations:` allow-list, declared before the macro):
41
+ # * has_many / has_one — children are deep-copied. A child whose class
42
+ # also includes Duplicable is copied via ITS OWN `duplicate` (own
43
+ # resets, own nested associations) — recursive graphs stay declarative.
44
+ # * has_and_belongs_to_many — the copy links to the SAME records (join
45
+ # rows are duplicated, the associated records are not).
46
+ # * has_many :through — rejected; duplicate the direct association.
47
+ # * belongs_to — rejected; a copy shares its parent by keeping the FK.
48
+ #
49
+ # `duplicate` returns an UNSAVED record with unsaved children (persisted
50
+ # together by `duplicate!` / `save!` via autosave). Override
51
+ # `on_duplicate(copy)` for custom tweaks — it runs last, before return.
52
+ module Duplicable
53
+ extend ActiveSupport::Concern
54
+
55
+ LABEL = "ConcernsOnRails::Models::Duplicable".freeze
56
+ SUPPORTED_MACROS = %i[has_many has_one has_and_belongs_to_many].freeze
57
+ TIMESTAMP_COLUMNS = %w[created_at updated_at].freeze
58
+
59
+ included do
60
+ class_attribute :duplicable_config, instance_accessor: false,
61
+ default: { associations: [], reset: [], suffix: {} }.freeze
62
+ end
63
+
64
+ module ClassMethods
65
+ include ConcernsOnRails::Support::ColumnGuard
66
+
67
+ # Configure the copy rules. Optional — `duplicate` works with bare
68
+ # `include` (attribute copy + the automatic identity resets).
69
+ def duplicable_by(associations: [], reset: [], suffix: {})
70
+ associations = Array(associations).map(&:to_sym)
71
+ reset = Array(reset).map(&:to_sym)
72
+ suffix = suffix.to_h { |field, text| [field.to_sym, text.to_s] }
73
+
74
+ duplicable_validate_associations!(associations)
75
+ ensure_columns!(LABEL, reset) unless reset.empty?
76
+ ensure_columns!(LABEL, suffix.keys) unless suffix.empty?
77
+
78
+ self.duplicable_config = { associations: associations, reset: reset, suffix: suffix }.freeze
79
+ end
80
+
81
+ private
82
+
83
+ def duplicable_validate_associations!(names)
84
+ names.each do |name|
85
+ reflection = reflect_on_association(name)
86
+ if reflection.nil?
87
+ raise ArgumentError,
88
+ "#{LABEL}: no association `#{name}` — declare it before `duplicable_by` " \
89
+ "(the CounterCacheable convention)"
90
+ end
91
+ duplicable_validate_reflection!(name, reflection)
92
+ end
93
+ end
94
+
95
+ def duplicable_validate_reflection!(name, reflection)
96
+ if reflection.through_reflection
97
+ raise ArgumentError,
98
+ "#{LABEL}: `#{name}` is a has_many :through association — duplicate the direct " \
99
+ "association instead (the through rows follow from it)"
100
+ end
101
+ return if SUPPORTED_MACROS.include?(reflection.macro)
102
+
103
+ raise ArgumentError,
104
+ "#{LABEL}: `#{name}` is a #{reflection.macro} association; only " \
105
+ "#{SUPPORTED_MACROS.join(' / ')} can be duplicated (a copy shares its belongs_to parents)"
106
+ end
107
+ end
108
+
109
+ # Override point — receives the UNSAVED copy as the last step of
110
+ # `duplicate`, so tweaks apply before any save.
111
+ def on_duplicate(_copy); end
112
+
113
+ # Unsaved deep copy: attributes via `dup`, identity columns blanked,
114
+ # `reset:` columns blanked, `suffix:` strings appended, `overrides`
115
+ # assigned, allow-listed associations copied, then `on_duplicate`.
116
+ def duplicate(overrides = {})
117
+ copy = dup
118
+ duplicable_reset_attributes(copy)
119
+ duplicable_apply_suffixes(copy)
120
+ overrides.each { |attribute, value| copy.public_send("#{attribute}=", value) }
121
+ duplicable_copy_associations(copy)
122
+ on_duplicate(copy)
123
+ copy
124
+ end
125
+
126
+ # Persisted deep copy — the copy and its copied children save together
127
+ # (autosave) inside one transaction. Returns the saved copy.
128
+ def duplicate!(overrides = {})
129
+ copy = duplicate(overrides)
130
+ transaction { copy.save! }
131
+ copy
132
+ end
133
+
134
+ private
135
+
136
+ def duplicable_reset_attributes(copy)
137
+ (duplicable_auto_reset_columns + self.class.duplicable_config[:reset]).each do |column|
138
+ copy[column] = nil if copy.class.column_names.include?(column.to_s)
139
+ end
140
+ copy[self.class.lockable_attempts_field] = 0 if duplicable_concern?(Lockable)
141
+ end
142
+
143
+ def duplicable_apply_suffixes(copy)
144
+ self.class.duplicable_config[:suffix].each do |field, text|
145
+ copy[field] = "#{copy[field]}#{text}" if copy[field].present?
146
+ end
147
+ end
148
+
149
+ # Identity-bearing columns owned by sibling concerns (see module docs).
150
+ def duplicable_auto_reset_columns
151
+ columns = TIMESTAMP_COLUMNS.dup
152
+ columns.concat(duplicable_generator_columns)
153
+ columns << self.class.auditable_into if duplicable_concern?(Auditable)
154
+ columns << self.class.soft_delete_field if duplicable_concern?(SoftDeletable)
155
+ columns << self.class.lockable_locked_at_field if duplicable_concern?(Lockable)
156
+ columns
157
+ end
158
+
159
+ # Columns whose values are generated per record (slug, tokens, sequence
160
+ # numbers) — each concern regenerates them on the copy's save.
161
+ def duplicable_generator_columns
162
+ columns = []
163
+ columns << self.class.friendly_id_config.slug_column if duplicable_concern?(Sluggable)
164
+ columns.concat(self.class.tokenizable_fields.keys) if duplicable_concern?(Tokenizable)
165
+ columns << self.class.hashable_field if duplicable_concern?(Hashable) && self.class.hashable_field
166
+ columns.concat(duplicable_sequence_columns) if duplicable_concern?(Sequenceable)
167
+ columns
168
+ end
169
+
170
+ def duplicable_sequence_columns
171
+ self.class.sequenceable_config.flat_map do |field, cfg|
172
+ [field, cfg[:into]].compact
173
+ end
174
+ end
175
+
176
+ def duplicable_concern?(concern)
177
+ self.class.include?(concern)
178
+ end
179
+
180
+ def duplicable_copy_associations(copy)
181
+ self.class.duplicable_config[:associations].each do |name|
182
+ reflection = self.class.reflect_on_association(name)
183
+ case reflection.macro
184
+ when :has_many
185
+ public_send(name).each { |child| copy.public_send(name) << duplicable_child_copy(child) }
186
+ when :has_one
187
+ child = public_send(name)
188
+ copy.public_send("#{name}=", duplicable_child_copy(child)) if child
189
+ when :has_and_belongs_to_many
190
+ copy.public_send("#{name}=", public_send(name).to_a)
191
+ end
192
+ end
193
+ end
194
+
195
+ # A child that is itself Duplicable copies by its OWN rules; anything
196
+ # else gets a dup with the timestamps blanked (AR preserves a present
197
+ # created_at on save).
198
+ def duplicable_child_copy(child)
199
+ return child.duplicate if child.class.include?(Duplicable)
200
+
201
+ plain = child.dup
202
+ TIMESTAMP_COLUMNS.each { |column| plain[column] = nil if plain.class.column_names.include?(column) }
203
+ plain
204
+ end
205
+ end
206
+ end
207
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.22.0".freeze
2
+ VERSION = "1.23.0".freeze
3
3
  end
@@ -96,6 +96,8 @@ require "concerns_on_rails/models/aliasable"
96
96
  require "concerns_on_rails/models/storable"
97
97
  require "concerns_on_rails/models/counter_cacheable"
98
98
  require "concerns_on_rails/models/encryptable"
99
+ require "concerns_on_rails/models/anonymizable"
100
+ require "concerns_on_rails/models/duplicable"
99
101
 
100
102
  # Controller concerns
101
103
  require "concerns_on_rails/controllers/paginatable"
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.22.0
4
+ version: 1.23.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-26 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -92,8 +92,10 @@ files:
92
92
  - lib/concerns_on_rails/models/activatable.rb
93
93
  - lib/concerns_on_rails/models/addressable.rb
94
94
  - lib/concerns_on_rails/models/aliasable.rb
95
+ - lib/concerns_on_rails/models/anonymizable.rb
95
96
  - lib/concerns_on_rails/models/auditable.rb
96
97
  - lib/concerns_on_rails/models/counter_cacheable.rb
98
+ - lib/concerns_on_rails/models/duplicable.rb
97
99
  - lib/concerns_on_rails/models/encryptable.rb
98
100
  - lib/concerns_on_rails/models/expirable.rb
99
101
  - lib/concerns_on_rails/models/hashable.rb