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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +66 -3
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/anonymizable.rb +218 -0
- data/lib/concerns_on_rails/models/duplicable.rb +207 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba3e5dc1bc6af51ec2bd4e29946973c5fc68736d4c1994fdbd472874f965c4bc
|
|
4
|
+
data.tar.gz: 16b2372635abe33484efabcae5407d1debe5a0627df9df0496a04e930c46ec65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|