concerns_on_rails 1.25.0 → 1.26.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 +26 -0
- data/README.md +145 -55
- data/lib/concerns_on_rails/models/anonymizable.rb +42 -13
- data/lib/concerns_on_rails/models/counter_cacheable.rb +41 -28
- data/lib/concerns_on_rails/models/monetizable.rb +6 -1
- data/lib/concerns_on_rails/models/publishable.rb +10 -1
- data/lib/concerns_on_rails/models/sequenceable.rb +7 -17
- data/lib/concerns_on_rails/models/sluggable.rb +8 -5
- data/lib/concerns_on_rails/models/soft_deletable.rb +17 -10
- data/lib/concerns_on_rails/models/sortable.rb +21 -7
- data/lib/concerns_on_rails/models/stateable.rb +27 -2
- data/lib/concerns_on_rails/models/taggable.rb +31 -8
- data/lib/concerns_on_rails/support/sequence_calculator.rb +0 -4
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7552b47dbc09add52b4bb8d519e8a8d4ed4e49590457114003fa83b6e9b7036e
|
|
4
|
+
data.tar.gz: b03171261ba6e24865ce46ab0c1e77a234cfec514e45e50a2dd055e82b5da287
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 461bbe636a2537cc5b60fe1eb5be3c080c115a3f119ca057a5f75d24dd54da44a6f5833a1f2baff6d2562d829fa77f98e7d1494e57cd8e1402b513f697025d61
|
|
7
|
+
data.tar.gz: 046a10782bc2259cf8911610a13b71289aa77ce2a1164045fd524fc785b58cc6061756ea99fba3f147d6964145641f63e03ed50b8530656b9a7269a4b83ce2bf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.26.0 (2026-08-24)
|
|
4
|
+
|
|
5
|
+
A fixes-and-performance round from a full audit of the 25 model concerns: one privacy bug (Anonymizable left Encryptable blind-index fingerprints queryable after erasure), four silent-misbehavior fixes, five query-count reductions, and three hardening changes. No new concerns. 1173 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Models::Anonymizable × Encryptable**: `anonymize!` writes via `update_columns`, which skips `before_save` — so Encryptable's blind-index refresh never ran and the `<field>_bidx` column kept the deterministic fingerprint of the ERASED value: `find_by_<field>(old_pii)` still resolved the record after erasure. The erasure payload now rewrites the blind-index column in the same single UPDATE (the fingerprint of the anonymized value; nil when the strategy nullifies), so the erased value stops resolving.
|
|
9
|
+
- **Models::Publishable**: `publish_at!(1.day.from_now)` on a BOOLEAN publishable column now raises `ArgumentError` — the Time used to cast to `true` and silently publish NOW instead of scheduling (a boolean column cannot represent a future publish; use `publish!`/`unpublish!` or a datetime column).
|
|
10
|
+
- **Models::Monetizable**: `subunit_to_unit:` is coerced to Integer at the macro. A String like `"100"` passed the old `.to_i.positive?` validation but was stored raw — the writer's `BigDecimal * "100"` raised TypeError (swallowed to nil by the form-garbage rescue, silently nil-ing every assignment) and the reader's division raised outright.
|
|
11
|
+
- **Models::Taggable**: input containing the delimiter now splits into multiple tags eagerly and identically everywhere — `add_tags("a,b")` adds "a" and "b"; `tag_list=`, `remove_tags`, and `tagged_with?` (AND semantics, mirroring the class-level scope) agree. A tag can never survive with the delimiter inside it (the column format cannot escape it), so pre-1.26 such input silently split on the NEXT normalize pass instead.
|
|
12
|
+
- **Models::Sortable**: `sortable_by` raises `ArgumentError` on unknown trailing options (a typo'd `ad_new_at:` used to vanish into `**field_options`), on a multi-pair Hash (only the first pair was read), and on an invalid direction (previously coerced to `:asc` silently — reordering the whole default scope without a whisper).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Models::Stateable**: `stateable_by` validates its options — unknown keys raise (previously ignored silently). New `lock: true` option: guarded `<event>!` transitions take a row lock (`SELECT ... FOR UPDATE`) and re-check the guard against the fresh row, closing the check-then-write race where two concurrent transitions both passed the in-memory guard. Off by default; requires a clean record (`with_lock` reloads) and costs a SELECT per transition.
|
|
16
|
+
- **Models::Sluggable**: `sluggable_by ..., scope:` accepts an association name (`scope: :account`) — friendly_id resolves association scopes itself, but ColumnGuard rejected them as missing columns. Column scopes (`scope: :account_id`) validate exactly as before.
|
|
17
|
+
- **Models::SoftDeletable**: `deleted_within` emits a table-qualified Arel predicate, so the scope stays unambiguous inside joins against tables sharing the column name.
|
|
18
|
+
|
|
19
|
+
### Performance
|
|
20
|
+
- **Models::CounterCacheable**: counter adjustments are grouped per (parent class, parent id) and flushed as ONE `update_counters` UPDATE — a model with sibling counters on the same parent (`comments_count` + `approved_comments_count`) used to issue one statement per rule per save. A reparent stays at one UPDATE per parent, both counters batched.
|
|
21
|
+
- **Models::Anonymizable**: `anonymize_all!` streams in PK batches (`find_each`) instead of loading the whole relation, filters already-stamped rows DB-side, and skips the per-record `reload` (batch instances are discarded) — two queries saved per record on large erasure jobs.
|
|
22
|
+
- **Models::SoftDeletable**: the `soft_delete_all` / `restore_all` slow paths stream via `find_each` with DB-side filtering instead of `to_a`-loading the relation (memory-bounded; the Integer-count contract, rollback semantics, and the single-UPDATE fast path are unchanged).
|
|
23
|
+
- **Models::Sequenceable**: creates no longer run the `exists?` probe after computing MAX+1 — within one consistent read MAX+1 cannot be taken, so the probe re-verified a tautology on EVERY create (and could not close the concurrent-insert race anyway; the scoped unique index does, per the module docs). One query saved per create.
|
|
24
|
+
- **Models::Taggable**: `all_tags` dedupes DB-side (`where.not(nil).distinct.pluck`), so identical tag strings ship over the wire once instead of once per row.
|
|
25
|
+
|
|
26
|
+
### Internal
|
|
27
|
+
- Regression specs for every fix — the Anonymizable blind-index pair doubles as the previously-missing Encryptable-composition coverage; CounterCacheable gained SQL statement-count specs; Sequenceable gained a no-probe query-count spec. Sluggable's `class_methods do` converted to a real `ClassMethods` module (the Stateable precedent).
|
|
28
|
+
|
|
3
29
|
## 1.25.0 (2026-08-16)
|
|
4
30
|
|
|
5
31
|
One new controller concern — Permittable, typed/validated params contracts with a boot-time schema-drift guard — developed here and shipped as the standalone [`permittable` gem](https://rubygems.org/gems/permittable) (new runtime dependency; `ConcernsOnRails::Controllers::Permittable` is an alias). 1160 examples, 0 failures.
|
data/README.md
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
One `include`, one declarative macro — done.
|
|
7
7
|
|
|
8
8
|
[](https://rubygems.org/gems/concerns_on_rails)
|
|
9
|
-
[](https://rubygems.org/gems/concerns_on_rails)
|
|
9
|
+
[](https://rubygems.org/gems/concerns_on_rails)
|
|
10
|
+
[](https://rubygems.org/gems/concerns_on_rails/versions)
|
|
10
11
|
[](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
|
|
12
|
+
[](https://vsn2015.github.io/concerns_on_rails)
|
|
11
13
|
[](https://www.ruby-lang.org)
|
|
12
14
|
[](https://rubyonrails.org)
|
|
13
15
|
[](#-license)
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
### [📖 **Documentation**](https://vsn2015.github.io/concerns_on_rails) · [💎 **RubyGems**](https://rubygems.org/gems/concerns_on_rails) · [📝 **Changelog**](CHANGELOG.md) · [🐛 **Issues**](https://github.com/VSN2015/concerns_on_rails/issues)
|
|
18
|
+
|
|
19
|
+
🧩 **26 model concerns** · 🎮 **16 controller concerns** · 🪶 **lean deps** · ✅ **schema-validated** · 🧪 **1,160 specs**
|
|
16
20
|
|
|
17
21
|
</div>
|
|
18
22
|
|
|
@@ -32,64 +36,108 @@ end
|
|
|
32
36
|
Article.published.without_deleted.find("hello-world")
|
|
33
37
|
```
|
|
34
38
|
|
|
39
|
+
> 📖 **Prefer browsable docs?** Every concern has its own searchable, deep-linkable page
|
|
40
|
+
> (dark mode included) at **[vsn2015.github.io/concerns_on_rails](https://vsn2015.github.io/concerns_on_rails)** —
|
|
41
|
+
> same content as this README, one page per concern. For install stats, every released
|
|
42
|
+
> version, and the live download counter, see the gem page:
|
|
43
|
+
> **[rubygems.org/gems/concerns_on_rails](https://rubygems.org/gems/concerns_on_rails)**.
|
|
44
|
+
|
|
35
45
|
---
|
|
36
46
|
|
|
37
47
|
## 📚 Table of Contents
|
|
38
48
|
|
|
39
|
-
[Why this gem?](#-why-this-gem) · [Installation](#-installation) · [Compatibility](#-compatibility) · [Quick Start](#-quick-start) · [Module paths](#-module-paths--namespacing) · [Development](#-development) · [Contributing](#-contributing) · [License](#-license)
|
|
49
|
+
[Find your concern](#-find-your-concern) · [Why this gem?](#-why-this-gem) · [Installation](#-installation) · [Compatibility](#-compatibility) · [Quick Start](#-quick-start) · [Module paths](#-module-paths--namespacing) · [AI assistants](#-using-this-gem-with-ai-assistants) · [Development](#-development) · [Contributing](#-contributing) · [License](#-license)
|
|
50
|
+
|
|
51
|
+
Every concern below links to its section in this README **and** to its standalone page on the [docs site](https://vsn2015.github.io/concerns_on_rails) (📖).
|
|
40
52
|
|
|
41
53
|
### 🧱 Model concerns
|
|
42
54
|
|
|
43
|
-
| Concern | What it does |
|
|
44
|
-
|
|
45
|
-
| [📝 Sluggable](#-sluggable) | URL-friendly slugs |
|
|
46
|
-
| [🔢 Sortable](#-sortable) | List ordering via `acts_as_list` |
|
|
47
|
-
| [📤 Publishable](#-publishable) | `published_at` timestamp publishing |
|
|
48
|
-
| [❌ SoftDeletable](#-softdeletable) | Soft delete with scopes & hooks |
|
|
49
|
-
| [🔐 Hashable](#-hashable) | Auto-generate tokens / UUIDs / codes |
|
|
50
|
-
| [🗓️ Schedulable](#-schedulable) | `starts_at` / `ends_at` time windows |
|
|
51
|
-
| [⏳ Expirable](#-expirable) | Single-timestamp expiry |
|
|
52
|
-
| [✨ Normalizable](#-normalizable) | Attribute normalization (`:email`, `:phone`, …) |
|
|
53
|
-
| [🔍 Searchable](#-searchable) | LIKE / ILIKE search across columns |
|
|
54
|
-
| [✅ Activatable](#-activatable) | Boolean active / inactive toggle |
|
|
55
|
-
| [🔑 Tokenizable](#-tokenizable) | Security tokens with timing-safe lookup |
|
|
56
|
-
| [🧾 Sequenceable](#-sequenceable) | Ordered, human-friendly reference numbers |
|
|
57
|
-
| [🔄 Stateable](#-stateable) | Lightweight string-backed state machine |
|
|
58
|
-
| [🏠 Addressable](#-addressable) | Postal address normalization + validation |
|
|
59
|
-
| [🏷️ Taggable](#-taggable) | Lightweight tagging over a single column |
|
|
60
|
-
| [🧼 Sanitizable](#-sanitizable) | Opt-in HTML sanitization (XSS defense) |
|
|
61
|
-
| [🙈 Maskable](#-maskable) | Non-destructive display masking |
|
|
62
|
-
| [💰 Monetizable](#-monetizable) | Integer-cents money columns (BigDecimal) |
|
|
63
|
-
| [📜 Auditable](#-auditable) | Single-column change history ("paper_trail-lite") |
|
|
64
|
-
| [🔐 Lockable](#-lockable) | Failed-attempt tracking + account lockout |
|
|
65
|
-
| [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases |
|
|
66
|
-
| [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
|
|
67
|
-
| [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
|
|
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") |
|
|
55
|
+
| Concern | What it does | Docs |
|
|
56
|
+
|---------|--------------|:----:|
|
|
57
|
+
| [📝 Sluggable](#-sluggable) | URL-friendly slugs | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sluggable) |
|
|
58
|
+
| [🔢 Sortable](#-sortable) | List ordering via `acts_as_list` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sortable) |
|
|
59
|
+
| [📤 Publishable](#-publishable) | `published_at` timestamp publishing | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/publishable) |
|
|
60
|
+
| [❌ SoftDeletable](#-softdeletable) | Soft delete with scopes & hooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/soft-deletable) |
|
|
61
|
+
| [🔐 Hashable](#-hashable) | Auto-generate tokens / UUIDs / codes | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/hashable) |
|
|
62
|
+
| [🗓️ Schedulable](#-schedulable) | `starts_at` / `ends_at` time windows | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/schedulable) |
|
|
63
|
+
| [⏳ Expirable](#-expirable) | Single-timestamp expiry | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/expirable) |
|
|
64
|
+
| [✨ Normalizable](#-normalizable) | Attribute normalization (`:email`, `:phone`, …) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/normalizable) |
|
|
65
|
+
| [🔍 Searchable](#-searchable) | LIKE / ILIKE search across columns | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/searchable) |
|
|
66
|
+
| [✅ Activatable](#-activatable) | Boolean active / inactive toggle | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/activatable) |
|
|
67
|
+
| [🔑 Tokenizable](#-tokenizable) | Security tokens with timing-safe lookup | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/tokenizable) |
|
|
68
|
+
| [🧾 Sequenceable](#-sequenceable) | Ordered, human-friendly reference numbers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sequenceable) |
|
|
69
|
+
| [🔄 Stateable](#-stateable) | Lightweight string-backed state machine | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/stateable) |
|
|
70
|
+
| [🏠 Addressable](#-addressable) | Postal address normalization + validation | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/addressable) |
|
|
71
|
+
| [🏷️ Taggable](#-taggable) | Lightweight tagging over a single column | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/taggable) |
|
|
72
|
+
| [🧼 Sanitizable](#-sanitizable) | Opt-in HTML sanitization (XSS defense) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sanitizable) |
|
|
73
|
+
| [🙈 Maskable](#-maskable) | Non-destructive display masking | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/maskable) |
|
|
74
|
+
| [💰 Monetizable](#-monetizable) | Integer-cents money columns (BigDecimal) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/monetizable) |
|
|
75
|
+
| [📜 Auditable](#-auditable) | Single-column change history ("paper_trail-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/auditable) |
|
|
76
|
+
| [🔐 Lockable](#-lockable) | Failed-attempt tracking + account lockout | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/lockable) |
|
|
77
|
+
| [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/aliasable) |
|
|
78
|
+
| [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/storable) |
|
|
79
|
+
| [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/counter-cacheable) |
|
|
80
|
+
| [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/encryptable) |
|
|
81
|
+
| [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/anonymizable) |
|
|
82
|
+
| [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/duplicable) |
|
|
71
83
|
|
|
72
84
|
### 🎮 Controller concerns
|
|
73
85
|
|
|
74
|
-
| Concern | What it does |
|
|
75
|
-
|
|
76
|
-
| [📄 Paginatable](#-paginatable) | Offset pagination with headers |
|
|
77
|
-
| [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers |
|
|
78
|
-
| [🔎 Filterable](#-filterable) | Declarative URL-param filters |
|
|
79
|
-
| [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list |
|
|
80
|
-
| [📦 Respondable](#-respondable) | Standardized JSON envelopes |
|
|
81
|
-
| [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers |
|
|
82
|
-
| [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets |
|
|
83
|
-
| [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL |
|
|
84
|
-
| [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` |
|
|
85
|
-
| [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate |
|
|
86
|
-
| [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) |
|
|
87
|
-
| [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie |
|
|
88
|
-
| [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay |
|
|
89
|
-
| [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks |
|
|
90
|
-
| [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 |
|
|
91
|
-
| [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` |
|
|
92
|
-
| [🛂 Permittable](#-permittable) | Typed, validated params contracts + schema-drift guard |
|
|
86
|
+
| Concern | What it does | Docs |
|
|
87
|
+
|---------|--------------|:----:|
|
|
88
|
+
| [📄 Paginatable](#-paginatable) | Offset pagination with headers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/paginatable) |
|
|
89
|
+
| [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cursor-paginatable) |
|
|
90
|
+
| [🔎 Filterable](#-filterable) | Declarative URL-param filters | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/filterable) |
|
|
91
|
+
| [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sortable-controller) |
|
|
92
|
+
| [📦 Respondable](#-respondable) | Standardized JSON envelopes | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/respondable) |
|
|
93
|
+
| [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/error-handleable) |
|
|
94
|
+
| [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/includable) |
|
|
95
|
+
| [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/secure-headable) |
|
|
96
|
+
| [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/localizable) |
|
|
97
|
+
| [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/authorizable) |
|
|
98
|
+
| [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/throttleable) |
|
|
99
|
+
| [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/timezoneable) |
|
|
100
|
+
| [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/idempotentable) |
|
|
101
|
+
| [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/webhook-verifiable) |
|
|
102
|
+
| [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/deprecatable) |
|
|
103
|
+
| [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cacheable) |
|
|
104
|
+
| [🛂 Permittable](#-permittable) | Typed, validated params contracts + schema-drift guard | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/permittable) |
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 🧭 Find your concern
|
|
109
|
+
|
|
110
|
+
Forty-three concerns is a lot of menu. Start from the problem instead:
|
|
111
|
+
|
|
112
|
+
| "I need to…" | Reach for |
|
|
113
|
+
|--------------|-----------|
|
|
114
|
+
| Give records pretty URLs — `/posts/hello-world`, not `/posts/42` | [📝 Sluggable](#-sluggable) |
|
|
115
|
+
| Let users trash records, then restore them | [❌ SoftDeletable](#-softdeletable) |
|
|
116
|
+
| Schedule a post to go live Friday at 9am | [📤 Publishable](#-publishable) |
|
|
117
|
+
| Support drag-and-drop reordering | [🔢 Sortable](#-sortable) |
|
|
118
|
+
| Issue API keys / invite codes with timing-safe lookup | [🔑 Tokenizable](#-tokenizable) |
|
|
119
|
+
| Number invoices like `INV-2026-00042`, per account, resetting yearly | [🧾 Sequenceable](#-sequenceable) |
|
|
120
|
+
| Add a small state machine without the AASM dependency | [🔄 Stateable](#-stateable) |
|
|
121
|
+
| Encrypt SSNs at rest and still `find_by` them | [🔏 Encryptable](#-encryptable) |
|
|
122
|
+
| Handle a GDPR "delete my data" request in one call | [🕵️ Anonymizable](#-anonymizable) |
|
|
123
|
+
| Know who changed which field, and when | [📜 Auditable](#-auditable) |
|
|
124
|
+
| Lock accounts after 5 failed logins | [🔐 Lockable](#-lockable) |
|
|
125
|
+
| Keep typed, defaulted settings in one JSON column | [⚙️ Storable](#-storable) |
|
|
126
|
+
| Maintain `approved_comments_count` next to `comments_count` | [🧮 CounterCacheable](#-countercacheable) |
|
|
127
|
+
| Ship a "duplicate this invoice" button (line items included) | [🧬 Duplicable](#-duplicable) |
|
|
128
|
+
| Handle money without ever touching a Float | [💰 Monetizable](#-monetizable) |
|
|
129
|
+
| Paginate a JSON index — page numbers or infinite scroll | [📄 Paginatable](#-paginatable) / [🧭 CursorPaginatable](#-cursorpaginatable) |
|
|
130
|
+
| Turn `?status=published&sort=title` into scopes safely | [🔎 Filterable](#-filterable) + [↕️ Sortable](#-sortable-controller) |
|
|
131
|
+
| Render one consistent JSON envelope, errors included | [📦 Respondable](#-respondable) + [🛟 ErrorHandleable](#-errorhandleable) |
|
|
132
|
+
| Rate-limit login attempts per IP | [🚦 Throttleable](#-throttleable) |
|
|
133
|
+
| Make payment retries safe (Stripe-style `Idempotency-Key`) | [🔁 Idempotentable](#-idempotentable) |
|
|
134
|
+
| Verify Stripe / GitHub / Shopify webhook signatures | [🪝 WebhookVerifiable](#-webhookverifiable) |
|
|
135
|
+
| Retire `/api/v1` with proper `Deprecation` / `Sunset` headers | [🌅 Deprecatable](#-deprecatable) |
|
|
136
|
+
| Serve ETags + 304s so clients stop re-downloading JSON | [🗄️ Cacheable](#-cacheable) |
|
|
137
|
+
| Get strong params that also cast, bound-check, and default | [🛂 Permittable](#-permittable) |
|
|
138
|
+
|
|
139
|
+
Still browsing? The [docs site](https://vsn2015.github.io/concerns_on_rails) has **instant search**
|
|
140
|
+
across all 43 concerns — press <kbd>/</kbd> and type.
|
|
93
141
|
|
|
94
142
|
---
|
|
95
143
|
|
|
@@ -98,8 +146,10 @@ Article.published.without_deleted.find("hello-world")
|
|
|
98
146
|
- **Twenty-six model concerns + sixteen controller concerns**, all production-ready
|
|
99
147
|
- **One include, one macro** — no boilerplate, no glue code
|
|
100
148
|
- **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
|
|
101
|
-
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
149
|
+
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early — with a ready-to-paste `rails generate migration` hint when it doesn't
|
|
102
150
|
- **Composable** — concerns are independent; mix and match per model
|
|
151
|
+
- **Tested like an app, not a snippet** — **1,160 RSpec examples** run against a real database on every CI build
|
|
152
|
+
- **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
|
|
103
153
|
|
|
104
154
|
---
|
|
105
155
|
|
|
@@ -108,7 +158,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
108
158
|
Add to your application's `Gemfile`:
|
|
109
159
|
|
|
110
160
|
```ruby
|
|
111
|
-
gem "concerns_on_rails", "~> 1.
|
|
161
|
+
gem "concerns_on_rails", "~> 1.26"
|
|
112
162
|
```
|
|
113
163
|
|
|
114
164
|
Or pull the latest from GitHub:
|
|
@@ -1879,13 +1929,35 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1879
1929
|
|
|
1880
1930
|
---
|
|
1881
1931
|
|
|
1932
|
+
## 🤖 Using this gem with AI assistants
|
|
1933
|
+
|
|
1934
|
+
Working with Claude, Cursor, Copilot, or another AI coding agent? The docs are AI-ready — every page
|
|
1935
|
+
is plain Markdown, fetchable without JavaScript:
|
|
1936
|
+
|
|
1937
|
+
- **[`llms.txt`](https://vsn2015.github.io/concerns_on_rails/llms.txt)** — a machine-readable index of
|
|
1938
|
+
every concern document, following the [llms.txt convention](https://llmstxt.org)
|
|
1939
|
+
- **[`llms-full.txt`](https://vsn2015.github.io/concerns_on_rails/llms-full.txt)** — all 43 concern docs
|
|
1940
|
+
concatenated into one ~400 KB plain-text file, for one-shot context loading
|
|
1941
|
+
- **Per-concern Markdown** — `https://vsn2015.github.io/concerns_on_rails/concerns/<slug>.md`
|
|
1942
|
+
(e.g. [`concerns/sluggable.md`](https://vsn2015.github.io/concerns_on_rails/concerns/sluggable.md))
|
|
1943
|
+
when your agent only needs one concern's docs
|
|
1944
|
+
- This README also ships **inside the packaged gem**, so an agent reading your bundle already has the
|
|
1945
|
+
full reference locally
|
|
1946
|
+
|
|
1947
|
+
Point your agent at `llms.txt` for an overview, or paste a single concern's `.md` URL for focused context.
|
|
1948
|
+
|
|
1949
|
+
---
|
|
1950
|
+
|
|
1882
1951
|
## 🛠️ Development
|
|
1883
1952
|
|
|
1884
1953
|
```sh
|
|
1885
1954
|
bundle install # install dev dependencies
|
|
1886
|
-
bundle exec rspec # run the test suite
|
|
1955
|
+
bundle exec rspec # run the test suite (1,173 examples)
|
|
1887
1956
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1888
|
-
gem install ./concerns_on_rails-1.
|
|
1957
|
+
gem install ./concerns_on_rails-1.26.0.gem # install locally
|
|
1958
|
+
|
|
1959
|
+
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
1960
|
+
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
1889
1961
|
```
|
|
1890
1962
|
|
|
1891
1963
|
The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
|
|
@@ -1894,7 +1966,25 @@ The test suite uses an in-memory SQLite database and a lightweight `FakeControll
|
|
|
1894
1966
|
|
|
1895
1967
|
## 🤝 Contributing
|
|
1896
1968
|
|
|
1897
|
-
Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**.
|
|
1969
|
+
Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**.
|
|
1970
|
+
|
|
1971
|
+
- 🐛 [Open an issue](https://github.com/VSN2015/concerns_on_rails/issues) — a failing spec is the fastest path to a fix
|
|
1972
|
+
- 🔀 Send a PR — run `bundle exec rspec` first; every concern keeps its spec under `spec/concerns/`
|
|
1973
|
+
- 📖 Docs count too — each concern's page lives in [`docs/concerns/`](docs/concerns) and redeploys automatically
|
|
1974
|
+
- ⭐️ If this gem saved you an afternoon of boilerplate, a star helps other devs find it
|
|
1975
|
+
|
|
1976
|
+
---
|
|
1977
|
+
|
|
1978
|
+
## 🔗 Links
|
|
1979
|
+
|
|
1980
|
+
| Resource | Where |
|
|
1981
|
+
|----------|-------|
|
|
1982
|
+
| 📖 Documentation site | [vsn2015.github.io/concerns_on_rails](https://vsn2015.github.io/concerns_on_rails) |
|
|
1983
|
+
| 💎 Gem page — live download count, all released versions | [rubygems.org/gems/concerns_on_rails](https://rubygems.org/gems/concerns_on_rails) |
|
|
1984
|
+
| 📝 Changelog | [CHANGELOG.md](CHANGELOG.md) |
|
|
1985
|
+
| 🤖 AI/LLM docs index (`llms.txt`) | [vsn2015.github.io/concerns_on_rails/llms.txt](https://vsn2015.github.io/concerns_on_rails/llms.txt) |
|
|
1986
|
+
| 🐛 Issue tracker | [github.com/VSN2015/concerns_on_rails/issues](https://github.com/VSN2015/concerns_on_rails/issues) |
|
|
1987
|
+
| 🛂 Permittable (extracted sibling gem) | [github.com/VSN2015/permittable](https://github.com/VSN2015/permittable) |
|
|
1898
1988
|
|
|
1899
1989
|
---
|
|
1900
1990
|
|
|
@@ -104,14 +104,21 @@ module ConcernsOnRails
|
|
|
104
104
|
# Anonymize every matching record that isn't already stamped, in one
|
|
105
105
|
# transaction. Returns the Integer count of records anonymized (the
|
|
106
106
|
# 1.22 batch contract). Without a stamp column every record matches.
|
|
107
|
+
# Streams in PK batches (find_each) rather than loading the relation,
|
|
108
|
+
# filters stamped rows DB-side, and skips the per-record reload —
|
|
109
|
+
# the batch discards its instances, so reloading each one would cost
|
|
110
|
+
# a wasted SELECT per row.
|
|
107
111
|
def anonymize_all!
|
|
112
|
+
relation = anonymizable_stamp ? all.where(anonymizable_stamp => nil) : all
|
|
108
113
|
transaction do
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
count = 0
|
|
115
|
+
relation.find_each do |record|
|
|
116
|
+
next if record.anonymized?
|
|
111
117
|
|
|
112
|
-
record.
|
|
113
|
-
|
|
118
|
+
record.send(:anonymize_record!)
|
|
119
|
+
count += 1
|
|
114
120
|
end
|
|
121
|
+
count
|
|
115
122
|
end
|
|
116
123
|
end
|
|
117
124
|
|
|
@@ -159,14 +166,7 @@ module ConcernsOnRails
|
|
|
159
166
|
# Erase the configured fields in a single UPDATE (see the module docs for
|
|
160
167
|
# why validations and callbacks are deliberately skipped). Returns true.
|
|
161
168
|
def anonymize!
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
payload = anonymizable_payload
|
|
165
|
-
transaction do
|
|
166
|
-
before_anonymize
|
|
167
|
-
update_columns(payload)
|
|
168
|
-
after_anonymize
|
|
169
|
-
end
|
|
169
|
+
anonymize_record!
|
|
170
170
|
# update_columns leaves DB-serialized values (e.g. ciphertext) in the
|
|
171
171
|
# in-memory attributes; reload so readers decode through the types.
|
|
172
172
|
reload
|
|
@@ -182,6 +182,19 @@ module ConcernsOnRails
|
|
|
182
182
|
|
|
183
183
|
private
|
|
184
184
|
|
|
185
|
+
# The write itself, without the trailing reload — anonymize_all! goes
|
|
186
|
+
# through this directly because its instances are discarded.
|
|
187
|
+
def anonymize_record!
|
|
188
|
+
raise ArgumentError, "#{LABEL}: anonymize! cannot be called on a new record" if new_record?
|
|
189
|
+
|
|
190
|
+
payload = anonymizable_payload
|
|
191
|
+
transaction do
|
|
192
|
+
before_anonymize
|
|
193
|
+
update_columns(payload)
|
|
194
|
+
after_anonymize
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
185
198
|
# { column => value }: strategy output cast through the attribute's type,
|
|
186
199
|
# plus the stamp and — when an anonymized field is also audited — the
|
|
187
200
|
# cleared audit column. update_columns serializes each value through the
|
|
@@ -192,7 +205,9 @@ module ConcernsOnRails
|
|
|
192
205
|
payload = {}
|
|
193
206
|
self.class.anonymizable_rules.each do |field, strategy|
|
|
194
207
|
value = anonymizable_apply_strategy(strategy, public_send(field))
|
|
195
|
-
|
|
208
|
+
cast = self.class.type_for_attribute(field.to_s).cast(value)
|
|
209
|
+
payload[field] = cast
|
|
210
|
+
anonymizable_add_blind_index(payload, field, cast)
|
|
196
211
|
end
|
|
197
212
|
stamp = self.class.anonymizable_stamp
|
|
198
213
|
payload[stamp] = Time.zone.now if stamp
|
|
@@ -200,6 +215,20 @@ module ConcernsOnRails
|
|
|
200
215
|
payload
|
|
201
216
|
end
|
|
202
217
|
|
|
218
|
+
# update_columns skips before_save, so Encryptable's blind-index refresh
|
|
219
|
+
# never runs here. Without this, the `<field>_bidx` column would keep the
|
|
220
|
+
# deterministic fingerprint of the ERASED value — find_by_<field> with
|
|
221
|
+
# the old PII would still resolve the record after anonymization.
|
|
222
|
+
def anonymizable_add_blind_index(payload, field, value)
|
|
223
|
+
return unless self.class.respond_to?(:encryptable_rules)
|
|
224
|
+
|
|
225
|
+
rule = self.class.encryptable_rules[field]
|
|
226
|
+
return unless rule && rule[:blind_index]
|
|
227
|
+
|
|
228
|
+
payload[rule[:blind_index][:column]] =
|
|
229
|
+
ConcernsOnRails::Models::Encryptable.blind_fingerprint(rule, value)
|
|
230
|
+
end
|
|
231
|
+
|
|
203
232
|
def anonymizable_apply_strategy(strategy, value)
|
|
204
233
|
strategy.arity == 1 ? strategy.call(value) : strategy.call(value, self)
|
|
205
234
|
end
|
|
@@ -189,27 +189,30 @@ module ConcernsOnRails
|
|
|
189
189
|
private
|
|
190
190
|
|
|
191
191
|
def counter_cacheable_run_create
|
|
192
|
-
|
|
193
|
-
next unless counter_cacheable_counted_now?(rule)
|
|
194
|
-
|
|
195
|
-
counter_cacheable_adjust(rule, counter_cacheable_fk_value(rule), 1)
|
|
196
|
-
end
|
|
192
|
+
counter_cacheable_flush(counter_cacheable_presence_adjustments(1))
|
|
197
193
|
end
|
|
198
194
|
|
|
199
195
|
def counter_cacheable_run_destroy
|
|
200
|
-
|
|
201
|
-
next unless counter_cacheable_counted_now?(rule)
|
|
202
|
-
|
|
203
|
-
counter_cacheable_adjust(rule, counter_cacheable_fk_value(rule), -1)
|
|
204
|
-
end
|
|
196
|
+
counter_cacheable_flush(counter_cacheable_presence_adjustments(-1))
|
|
205
197
|
end
|
|
206
198
|
|
|
207
199
|
def counter_cacheable_run_update
|
|
208
|
-
|
|
200
|
+
counter_cacheable_flush(
|
|
201
|
+
self.class.counter_cacheable_rules.flat_map { |rule| counter_cacheable_update_adjustments(rule) }
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# create/destroy share one shape: ±1 on the current parent when counted.
|
|
206
|
+
def counter_cacheable_presence_adjustments(delta)
|
|
207
|
+
self.class.counter_cacheable_rules.filter_map do |rule|
|
|
208
|
+
next unless counter_cacheable_counted_now?(rule)
|
|
209
|
+
|
|
210
|
+
counter_cacheable_adjustment(rule, counter_cacheable_fk_value(rule), delta)
|
|
211
|
+
end
|
|
209
212
|
end
|
|
210
213
|
|
|
211
214
|
# The create × destroy × (reparent + condition-flip) matrix.
|
|
212
|
-
def
|
|
215
|
+
def counter_cacheable_update_adjustments(rule)
|
|
213
216
|
fk = counter_cacheable_reflection(rule).foreign_key.to_s
|
|
214
217
|
changes = counter_cacheable_changes
|
|
215
218
|
new_fk = self[fk]
|
|
@@ -219,31 +222,41 @@ module ConcernsOnRails
|
|
|
219
222
|
new_counted = counter_cacheable_counted_now?(rule)
|
|
220
223
|
|
|
221
224
|
if old_fk == new_fk
|
|
222
|
-
|
|
225
|
+
# Same parent — only a condition flip can change the count.
|
|
226
|
+
return [] if old_counted == new_counted
|
|
227
|
+
|
|
228
|
+
[counter_cacheable_adjustment(rule, new_fk, new_counted ? 1 : -1)]
|
|
223
229
|
else
|
|
224
|
-
|
|
230
|
+
# Foreign key changed — settle the old parent and the new one independently.
|
|
231
|
+
[(counter_cacheable_adjustment(rule, old_fk, -1) if old_counted),
|
|
232
|
+
(counter_cacheable_adjustment(rule, new_fk, 1) if new_counted)]
|
|
225
233
|
end
|
|
226
234
|
end
|
|
227
235
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return if old_counted == new_counted
|
|
236
|
+
def counter_cacheable_adjustment(rule, parent_id, delta)
|
|
237
|
+
return nil if parent_id.nil?
|
|
231
238
|
|
|
232
|
-
|
|
239
|
+
{ klass: counter_cacheable_reflection(rule).klass, parent_id: parent_id,
|
|
240
|
+
column: rule[:count_column], delta: delta, touch: rule[:touch] }
|
|
233
241
|
end
|
|
234
242
|
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
# One update_counters per distinct (parent class, parent id): sibling
|
|
244
|
+
# rules adjusting the same parent (comments_count + approved_comments_count)
|
|
245
|
+
# ride a single UPDATE instead of one statement each.
|
|
246
|
+
def counter_cacheable_flush(adjustments)
|
|
247
|
+
adjustments.compact.group_by { |adj| [adj[:klass], adj[:parent_id]] }.each do |(klass, parent_id), group|
|
|
248
|
+
counters = counter_cacheable_merged_counters(group)
|
|
249
|
+
next if counters.empty?
|
|
240
250
|
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
counters[:touch] = true if group.any? { |adj| adj[:touch] }
|
|
252
|
+
klass.update_counters(parent_id, counters)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
243
255
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
256
|
+
# Sum per column, dropping zero-sum entries (nothing to write).
|
|
257
|
+
def counter_cacheable_merged_counters(group)
|
|
258
|
+
group.each_with_object(Hash.new(0)) { |adj, acc| acc[adj[:column]] += adj[:delta] }
|
|
259
|
+
.reject { |_column, delta| delta.zero? }
|
|
247
260
|
end
|
|
248
261
|
|
|
249
262
|
def counter_cacheable_fk_value(rule)
|
|
@@ -43,7 +43,12 @@ module ConcernsOnRails
|
|
|
43
43
|
|
|
44
44
|
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :as cannot be combined with multiple fields" if as && fields.size > 1
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
# Coerce, don't just validate: a String like "100" passed the old
|
|
47
|
+
# `.to_i.positive?` check but was stored raw — the writer's
|
|
48
|
+
# `BigDecimal * "100"` then raised TypeError (swallowed to nil by the
|
|
49
|
+
# form-garbage rescue) and the reader's division raised outright.
|
|
50
|
+
subunit_to_unit = subunit_to_unit.to_i
|
|
51
|
+
unless subunit_to_unit.positive?
|
|
47
52
|
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :subunit_to_unit must be a positive integer"
|
|
48
53
|
end
|
|
49
54
|
|
|
@@ -133,10 +133,19 @@ module ConcernsOnRails
|
|
|
133
133
|
|
|
134
134
|
# Publish at an explicit time. A future time schedules the record.
|
|
135
135
|
# Fires the publish hooks (same state change as publish!, so since 1.22
|
|
136
|
-
# they no longer silently skip).
|
|
136
|
+
# they no longer silently skip). Raises on a boolean publishable column:
|
|
137
|
+
# the Time would cast to `true` and silently publish NOW instead of
|
|
138
|
+
# scheduling — a boolean column cannot represent a future publish.
|
|
137
139
|
# Example:
|
|
138
140
|
# record.publish_at!(1.day.from_now)
|
|
139
141
|
def publish_at!(time)
|
|
142
|
+
if self.class.publishable_boolean_column?
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"ConcernsOnRails::Models::Publishable: publish_at! needs a timestamp column, but " \
|
|
145
|
+
"'#{self.class.publishable_field}' is a boolean — a Time casts to true and would " \
|
|
146
|
+
"publish immediately. Use publish!/unpublish!, or a datetime column to schedule."
|
|
147
|
+
end
|
|
148
|
+
|
|
140
149
|
publishable_write_with_hooks(time, :publish)
|
|
141
150
|
end
|
|
142
151
|
|
|
@@ -33,7 +33,6 @@ module ConcernsOnRails
|
|
|
33
33
|
extend ActiveSupport::Concern
|
|
34
34
|
|
|
35
35
|
RESET_PERIODS = %i[never year month day].freeze
|
|
36
|
-
MAX_GENERATION_ATTEMPTS = 10
|
|
37
36
|
NAME = "ConcernsOnRails::Models::Sequenceable".freeze
|
|
38
37
|
|
|
39
38
|
included do
|
|
@@ -108,26 +107,17 @@ module ConcernsOnRails
|
|
|
108
107
|
end
|
|
109
108
|
|
|
110
109
|
# Assigns the sequence (and, when configured, the formatted string) only when
|
|
111
|
-
# the integer column is blank, so callers can pass an explicit value.
|
|
112
|
-
#
|
|
113
|
-
#
|
|
110
|
+
# the integer column is blank, so callers can pass an explicit value.
|
|
111
|
+
# MAX+1 (or start_at on an empty scope) cannot already be taken within the
|
|
112
|
+
# same consistent read — the pre-1.26 exists? probe re-verified that
|
|
113
|
+
# tautology with an extra query on EVERY create, and could not close the
|
|
114
|
+
# concurrent-insert race anyway. Concurrency is the scoped unique index's
|
|
115
|
+
# job (pair with Support::UniqueRetry around the create).
|
|
114
116
|
def assign_sequenceable_value(field)
|
|
115
117
|
cfg = self.class.sequenceable_config.fetch(field)
|
|
116
118
|
sequenceable_pin_created_at(cfg)
|
|
117
119
|
|
|
118
|
-
if self[field].blank?
|
|
119
|
-
candidate = self.class.send(:sequence_base_value, field, self, {})
|
|
120
|
-
attempts = 0
|
|
121
|
-
while self.class.send(:sequence_value_taken?, field, candidate, self, {})
|
|
122
|
-
attempts += 1
|
|
123
|
-
if attempts >= MAX_GENERATION_ATTEMPTS
|
|
124
|
-
raise "#{NAME}: could not find a free value for '#{field}' after " \
|
|
125
|
-
"#{MAX_GENERATION_ATTEMPTS} attempts — add a scoped unique index"
|
|
126
|
-
end
|
|
127
|
-
candidate += 1
|
|
128
|
-
end
|
|
129
|
-
self[field] = candidate
|
|
130
|
-
end
|
|
120
|
+
self[field] = self.class.send(:sequence_base_value, field, self, {}) if self[field].blank?
|
|
131
121
|
|
|
132
122
|
return unless cfg[:into] && self[cfg[:into]].blank?
|
|
133
123
|
|
|
@@ -52,7 +52,10 @@ module ConcernsOnRails
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# class methods
|
|
55
|
-
class_methods do
|
|
55
|
+
# A real module (not `class_methods do`) so the macro and its private
|
|
56
|
+
# helpers aren't constrained by Metrics/BlockLength (the Stateable
|
|
57
|
+
# precedent). ActiveSupport::Concern auto-extends ClassMethods.
|
|
58
|
+
module ClassMethods
|
|
56
59
|
include ConcernsOnRails::Support::ColumnGuard
|
|
57
60
|
|
|
58
61
|
# Define sluggable field, with optional friendly_id features.
|
|
@@ -64,11 +67,11 @@ module ConcernsOnRails
|
|
|
64
67
|
# sluggable_by :title, finders: true # Model.find accepts a slug directly
|
|
65
68
|
def sluggable_by(field, history: false, scope: nil, reserved_words: nil, finders: false)
|
|
66
69
|
self.sluggable_field = field.to_sym
|
|
67
|
-
# Validate the slug column too
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
+
# Validate the slug column too (a missing one used to fail at first save
|
|
71
|
+
# with an opaque friendly_id error); an association scope: is exempt.
|
|
72
|
+
scope_column = scope && reflect_on_association(scope.to_sym) ? nil : scope
|
|
70
73
|
ensure_columns!("ConcernsOnRails::Models::Sluggable",
|
|
71
|
-
[sluggable_field, friendly_id_config.slug_column,
|
|
74
|
+
[sluggable_field, friendly_id_config.slug_column, scope_column].compact,
|
|
72
75
|
types: { friendly_id_config.slug_column.to_sym => "string:uniq" })
|
|
73
76
|
return unless history || scope || reserved_words || finders
|
|
74
77
|
|
|
@@ -24,11 +24,12 @@ module ConcernsOnRails
|
|
|
24
24
|
# `with_deleted` peels off the default scope so deleted + non-deleted are both returned.
|
|
25
25
|
scope :with_deleted, -> { unscope(where: soft_delete_field) }
|
|
26
26
|
# Records soft-deleted within the last `duration` (e.g. `deleted_within(7.days)`).
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
27
|
+
# Arel `gteq` rather than an endless range (`x..`): AR only translates an
|
|
28
|
+
# endless range to `>=` on Rails 6.0+, but this gem supports Rails >= 5.0.
|
|
29
|
+
# arel_table also qualifies the column with the table name, so the scope
|
|
30
|
+
# stays unambiguous inside joins against tables sharing the column.
|
|
30
31
|
scope :deleted_within, lambda { |duration|
|
|
31
|
-
soft_deleted.where(
|
|
32
|
+
soft_deleted.where(arel_table[soft_delete_field].gteq(duration.ago))
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
# Hide soft-deleted rows from `.all` only when enabled (the default). The block is
|
|
@@ -64,16 +65,20 @@ module ConcernsOnRails
|
|
|
64
65
|
return all.where(soft_delete_field => nil).update_all(soft_delete_field => Time.zone.now)
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
# find_each streams in PK batches instead of materializing the whole
|
|
69
|
+
# relation; filtering deleted rows DB-side also skips loading them at
|
|
70
|
+
# all. Updated rows leave the filtered set, but pagination is strictly
|
|
71
|
+
# forward by id, so nothing is skipped or revisited.
|
|
67
72
|
transaction do
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
count = 0
|
|
74
|
+
all.where(soft_delete_field => nil).find_each do |record|
|
|
71
75
|
record.soft_delete! ||
|
|
72
76
|
raise(ActiveRecord::RecordNotSaved.new(
|
|
73
77
|
"ConcernsOnRails::Models::SoftDeletable: failed to soft-delete record", record
|
|
74
78
|
))
|
|
75
|
-
|
|
79
|
+
count += 1
|
|
76
80
|
end
|
|
81
|
+
count
|
|
77
82
|
end
|
|
78
83
|
end
|
|
79
84
|
|
|
@@ -102,13 +107,15 @@ module ConcernsOnRails
|
|
|
102
107
|
return soft_deleted.update_all(soft_delete_field => nil) if soft_delete_batch_fast_path?(:restore)
|
|
103
108
|
|
|
104
109
|
transaction do
|
|
105
|
-
|
|
110
|
+
count = 0
|
|
111
|
+
soft_deleted.find_each do |record|
|
|
106
112
|
record.restore! ||
|
|
107
113
|
raise(ActiveRecord::RecordNotSaved.new(
|
|
108
114
|
"ConcernsOnRails::Models::SoftDeletable: failed to restore record", record
|
|
109
115
|
))
|
|
110
|
-
|
|
116
|
+
count += 1
|
|
111
117
|
end
|
|
118
|
+
count
|
|
112
119
|
end
|
|
113
120
|
end
|
|
114
121
|
|
|
@@ -86,24 +86,38 @@ module ConcernsOnRails
|
|
|
86
86
|
private
|
|
87
87
|
|
|
88
88
|
def resolve_sortable_config(field_config, field_options)
|
|
89
|
-
|
|
89
|
+
if field_config.nil? && field_options.any?
|
|
90
|
+
# `sortable_by position: :desc` — the trailing keywords ARE the config.
|
|
91
|
+
field_config = field_options
|
|
92
|
+
elsif field_options.any?
|
|
93
|
+
# `sortable_by :position, ad_new_at: :top` — a typo'd option used to
|
|
94
|
+
# ride into **field_options and vanish silently.
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"ConcernsOnRails::Models::Sortable: unknown option(s): #{field_options.keys.join(', ')}"
|
|
97
|
+
end
|
|
90
98
|
# A bare `sortable_by` keeps the documented defaults (:position asc)
|
|
91
99
|
# instead of crashing on nil (pre-1.22 NoMethodError).
|
|
92
100
|
field_config = sortable_field || :position if field_config.nil?
|
|
93
101
|
|
|
94
102
|
field, direction = parse_sortable_config(field_config)
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
unless %i[asc desc].include?(direction)
|
|
104
|
+
# Raise instead of the old silent :asc fallback — a misspelled
|
|
105
|
+
# direction reordered the whole default scope without a whisper.
|
|
106
|
+
raise ArgumentError,
|
|
107
|
+
"ConcernsOnRails::Models::Sortable: direction must be :asc or :desc, got '#{direction}'"
|
|
108
|
+
end
|
|
97
109
|
[field, direction]
|
|
98
110
|
end
|
|
99
111
|
|
|
100
112
|
def parse_sortable_config(config)
|
|
101
113
|
if config.is_a?(Hash)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
if config.size > 1
|
|
115
|
+
raise ArgumentError,
|
|
116
|
+
"ConcernsOnRails::Models::Sortable: pass exactly one field => direction pair, " \
|
|
117
|
+
"got #{config.inspect}"
|
|
118
|
+
end
|
|
105
119
|
key, value = config.first
|
|
106
|
-
[key.to_sym, value.to_sym]
|
|
120
|
+
[key.to_sym, value.to_s.to_sym]
|
|
107
121
|
else
|
|
108
122
|
[config.to_sym, :asc]
|
|
109
123
|
end
|
|
@@ -29,13 +29,19 @@ module ConcernsOnRails
|
|
|
29
29
|
#
|
|
30
30
|
# Plus a generic `transition_to!(state)`.
|
|
31
31
|
#
|
|
32
|
-
# Options for stateable_by: default:, transitions:, prefix:, suffix:
|
|
32
|
+
# Options for stateable_by: default:, transitions:, prefix:, suffix:, lock:
|
|
33
33
|
# (prefix:/suffix: take `true` to use the field name, or a literal string/symbol).
|
|
34
34
|
#
|
|
35
35
|
# Notes:
|
|
36
36
|
# * String columns only (store the state name) — not integer-backed like Rails enum.
|
|
37
37
|
# * A state named like an AR method (`new`, `valid`) or a concern scope
|
|
38
38
|
# (`active`, `expired`) will clash — use prefix:/suffix: to disambiguate.
|
|
39
|
+
# * Guarded transitions check the in-memory state: two processes firing the
|
|
40
|
+
# same <event>! concurrently can both pass the guard (check-then-write).
|
|
41
|
+
# `lock: true` closes that race — each <event>! takes a row lock
|
|
42
|
+
# (SELECT ... FOR UPDATE) and re-checks the guard against the fresh row
|
|
43
|
+
# first. Requires a clean record (with_lock reloads; AR refuses to
|
|
44
|
+
# reload unsaved changes) and costs a SELECT per transition.
|
|
39
45
|
module Stateable
|
|
40
46
|
extend ActiveSupport::Concern
|
|
41
47
|
|
|
@@ -44,6 +50,9 @@ module ConcernsOnRails
|
|
|
44
50
|
# Raised when a guarded transition is attempted from a disallowed state.
|
|
45
51
|
class InvalidTransition < StandardError; end
|
|
46
52
|
|
|
53
|
+
# Valid stateable_by keyword options (everything besides field/states:).
|
|
54
|
+
OPTIONS = %i[default transitions prefix suffix lock].freeze
|
|
55
|
+
|
|
47
56
|
included do
|
|
48
57
|
class_attribute :stateable_field, instance_accessor: false
|
|
49
58
|
class_attribute :stateable_states, instance_accessor: false, default: []
|
|
@@ -51,6 +60,7 @@ module ConcernsOnRails
|
|
|
51
60
|
class_attribute :stateable_transitions, instance_accessor: false, default: {}
|
|
52
61
|
class_attribute :stateable_prefix, instance_accessor: false
|
|
53
62
|
class_attribute :stateable_suffix, instance_accessor: false
|
|
63
|
+
class_attribute :stateable_lock, instance_accessor: false, default: false
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# Move to any declared state by name, bypassing transition guards.
|
|
@@ -84,12 +94,16 @@ module ConcernsOnRails
|
|
|
84
94
|
private
|
|
85
95
|
|
|
86
96
|
def stateable_configure!(field, states, options)
|
|
97
|
+
unknown = options.keys - OPTIONS
|
|
98
|
+
raise ArgumentError, "#{LABEL}: unknown option(s): #{unknown.join(', ')}" if unknown.any?
|
|
99
|
+
|
|
87
100
|
self.stateable_field = field.to_sym
|
|
88
101
|
self.stateable_states = Array(states).map(&:to_sym)
|
|
89
102
|
self.stateable_default = options[:default]&.to_sym
|
|
90
103
|
self.stateable_transitions = options[:transitions] || {}
|
|
91
104
|
self.stateable_prefix = stateable_affix(options[:prefix])
|
|
92
105
|
self.stateable_suffix = stateable_affix(options[:suffix])
|
|
106
|
+
self.stateable_lock = options[:lock] ? true : false
|
|
93
107
|
ensure_columns!(LABEL, stateable_field, types: :string)
|
|
94
108
|
end
|
|
95
109
|
|
|
@@ -162,10 +176,21 @@ module ConcernsOnRails
|
|
|
162
176
|
private
|
|
163
177
|
|
|
164
178
|
# Instance-level guarded transition body, shared by every `<event>!`.
|
|
179
|
+
# With `lock: true` the guard is re-checked under a row lock (with_lock
|
|
180
|
+
# reloads, so the state read is the committed one) — closing the
|
|
181
|
+
# check-then-write race between two concurrent transitions.
|
|
182
|
+
def stateable_perform_transition!(field, to, from, event)
|
|
183
|
+
if self.class.stateable_lock && persisted?
|
|
184
|
+
with_lock { stateable_execute_transition!(field, to, from, event) }
|
|
185
|
+
else
|
|
186
|
+
stateable_execute_transition!(field, to, from, event)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
165
190
|
# Hooks and the state write share ONE transaction, so a raising
|
|
166
191
|
# after_transition rolls the state change back instead of leaving it
|
|
167
192
|
# committed with the side effect half-done (SoftDeletable's pattern).
|
|
168
|
-
def
|
|
193
|
+
def stateable_execute_transition!(field, to, from, event)
|
|
169
194
|
current = self[field].to_s
|
|
170
195
|
raise InvalidTransition, "#{self.class.name}: cannot #{event} from '#{self[field]}'" unless from.empty? || from.include?(current)
|
|
171
196
|
|
|
@@ -27,7 +27,10 @@ module ConcernsOnRails
|
|
|
27
27
|
#
|
|
28
28
|
# Notes:
|
|
29
29
|
# * Matching is boundary-safe ("rail" does not match "rails").
|
|
30
|
-
# * A tag
|
|
30
|
+
# * A tag cannot contain the delimiter (default ",") — input containing
|
|
31
|
+
# it is split into multiple tags on the spot (`add_tags("a,b")` adds
|
|
32
|
+
# "a" and "b"), everywhere, so what you read back always matches what
|
|
33
|
+
# a save would have produced.
|
|
31
34
|
# * Reach for acts-as-taggable-on when you need tag contexts, ownership,
|
|
32
35
|
# tag counts/clouds, or polymorphic tags shared across models.
|
|
33
36
|
module Taggable
|
|
@@ -70,8 +73,14 @@ module ConcernsOnRails
|
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
# All distinct tags currently stored across the table, sorted.
|
|
76
|
+
# distinct + NULL filter dedupe DB-side, so identical tag strings ship
|
|
77
|
+
# over the wire once instead of once per row.
|
|
73
78
|
def all_tags
|
|
74
|
-
|
|
79
|
+
where.not(taggable_field => nil)
|
|
80
|
+
.distinct
|
|
81
|
+
.pluck(taggable_field)
|
|
82
|
+
.flat_map { |raw| taggable_split(raw) }
|
|
83
|
+
.uniq.sort
|
|
75
84
|
end
|
|
76
85
|
|
|
77
86
|
# Split a raw stored column value into a normalized tag array.
|
|
@@ -87,8 +96,16 @@ module ConcernsOnRails
|
|
|
87
96
|
|
|
88
97
|
private
|
|
89
98
|
|
|
99
|
+
# Splits each entry on the delimiter before cleaning: a tag can never
|
|
100
|
+
# contain the delimiter (the column format has no way to escape it),
|
|
101
|
+
# so "a,b" was ALWAYS going to read back as two tags after the next
|
|
102
|
+
# normalize pass — splitting here makes that immediate and uniform
|
|
103
|
+
# instead of a silent later surprise.
|
|
90
104
|
def taggable_clean_all(names)
|
|
91
|
-
names.flatten
|
|
105
|
+
names.flatten
|
|
106
|
+
.flat_map { |t| t.to_s.split(taggable_delimiter) }
|
|
107
|
+
.map { |t| taggable_clean(t) }
|
|
108
|
+
.reject(&:blank?).uniq
|
|
92
109
|
end
|
|
93
110
|
|
|
94
111
|
# Boundary-safe match for one tag against the delimiter-joined column.
|
|
@@ -126,20 +143,23 @@ module ConcernsOnRails
|
|
|
126
143
|
end
|
|
127
144
|
|
|
128
145
|
def add_tags(*names)
|
|
129
|
-
self.tag_list = tag_list + names.flatten
|
|
146
|
+
self.tag_list = tag_list + names.flatten
|
|
130
147
|
tag_list
|
|
131
148
|
end
|
|
132
149
|
alias add_tag add_tags
|
|
133
150
|
|
|
134
151
|
def remove_tags(*names)
|
|
135
|
-
drop = names.flatten
|
|
152
|
+
drop = taggable_coerce(names.flatten)
|
|
136
153
|
self.tag_list = tag_list.reject { |t| drop.include?(t) }
|
|
137
154
|
tag_list
|
|
138
155
|
end
|
|
139
156
|
alias remove_tag remove_tags
|
|
140
157
|
|
|
158
|
+
# AND semantics for delimiter-containing input, mirroring the class-level
|
|
159
|
+
# tagged_with default: tagged_with?("a,b") is true when BOTH tags are set.
|
|
141
160
|
def tagged_with?(tag)
|
|
142
|
-
|
|
161
|
+
parts = self.class.taggable_split(tag.to_s)
|
|
162
|
+
parts.any? && (parts - tag_list).empty?
|
|
143
163
|
end
|
|
144
164
|
alias has_tag? tagged_with?
|
|
145
165
|
|
|
@@ -156,9 +176,12 @@ module ConcernsOnRails
|
|
|
156
176
|
self[field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
|
|
157
177
|
end
|
|
158
178
|
|
|
179
|
+
# Funnel every input shape through taggable_split so Strings and Arrays
|
|
180
|
+
# (and Array items that themselves contain the delimiter) normalize
|
|
181
|
+
# identically.
|
|
159
182
|
def taggable_coerce(value)
|
|
160
|
-
|
|
161
|
-
|
|
183
|
+
raw = value.is_a?(Array) ? value.join(self.class.taggable_delimiter) : value.to_s
|
|
184
|
+
self.class.taggable_split(raw)
|
|
162
185
|
end
|
|
163
186
|
end
|
|
164
187
|
end
|
|
@@ -15,10 +15,6 @@ module ConcernsOnRails
|
|
|
15
15
|
max ? max + 1 : cfg[:start_at]
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def sequence_value_taken?(field, candidate, record, scope_attrs)
|
|
19
|
-
sequence_relation(field, record, scope_attrs).exists?(field => candidate)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
18
|
# Relation of existing rows that share this record's scope (and period, when
|
|
23
19
|
# reset is enabled). Reads from `unscoped` so a model's default_scope never
|
|
24
20
|
# hides rows the counter must account for.
|
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.26.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-08-
|
|
11
|
+
date: 2026-08-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|