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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 423bbd556d23280595c6a456d16a18255ada3898f88169831005526795f936fc
4
- data.tar.gz: 96a50269d776642a46534f50864730ea54fc2f82d458ab3e0ef11424f742fc29
3
+ metadata.gz: 7552b47dbc09add52b4bb8d519e8a8d4ed4e49590457114003fa83b6e9b7036e
4
+ data.tar.gz: b03171261ba6e24865ce46ab0c1e77a234cfec514e45e50a2dd055e82b5da287
5
5
  SHA512:
6
- metadata.gz: 6c0b871a10e781fdd6d5fb9464eb8e245783adb2b3f20bb7067758934655ae8c82d0eb082d121e198e7a7a1040db60b467600bb8b8307a9484d034c1ebceecab
7
- data.tar.gz: e550786d60b36e5d44239d59a283776f4debba254a43eacb9b2141112a94a0af8d2b53feeebc4ee3e6528f0973117abfa89a1e04ae4033e7f7058e2e788e5d5a
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
  [![Gem Version](https://img.shields.io/gem/v/concerns_on_rails?logo=rubygems&logoColor=white&color=CC342D)](https://rubygems.org/gems/concerns_on_rails)
9
- [![Downloads](https://img.shields.io/gem/dt/concerns_on_rails?color=1f6feb)](https://rubygems.org/gems/concerns_on_rails)
9
+ [![Total Downloads](https://img.shields.io/gem/dt/concerns_on_rails?color=1f6feb&label=downloads)](https://rubygems.org/gems/concerns_on_rails)
10
+ [![Latest Version Downloads](https://img.shields.io/gem/dtv/concerns_on_rails?color=8250df&label=latest%20version)](https://rubygems.org/gems/concerns_on_rails/versions)
10
11
  [![CI](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml/badge.svg)](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
12
+ [![Docs](https://img.shields.io/badge/docs-vsn2015.github.io-6f42c1?logo=readthedocs&logoColor=white)](https://vsn2015.github.io/concerns_on_rails)
11
13
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
12
14
  [![Rails](https://img.shields.io/badge/rails-5.0--8.x-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
13
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](#-license)
14
16
 
15
- 🧩 **26 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; **schema-validated**
17
+ ### [📖 **Documentation**](https://vsn2015.github.io/concerns_on_rails) &nbsp;·&nbsp; [💎 **RubyGems**](https://rubygems.org/gems/concerns_on_rails) &nbsp;·&nbsp; [📝 **Changelog**](CHANGELOG.md) &nbsp;·&nbsp; [🐛 **Issues**](https://github.com/VSN2015/concerns_on_rails/issues)
18
+
19
+ 🧩 **26 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated** &nbsp;·&nbsp; 🧪 **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) &nbsp;·&nbsp; [Installation](#-installation) &nbsp;·&nbsp; [Compatibility](#-compatibility) &nbsp;·&nbsp; [Quick Start](#-quick-start) &nbsp;·&nbsp; [Module paths](#-module-paths--namespacing) &nbsp;·&nbsp; [Development](#-development) &nbsp;·&nbsp; [Contributing](#-contributing) &nbsp;·&nbsp; [License](#-license)
49
+ [Find your concern](#-find-your-concern) &nbsp;·&nbsp; [Why this gem?](#-why-this-gem) &nbsp;·&nbsp; [Installation](#-installation) &nbsp;·&nbsp; [Compatibility](#-compatibility) &nbsp;·&nbsp; [Quick Start](#-quick-start) &nbsp;·&nbsp; [Module paths](#-module-paths--namespacing) &nbsp;·&nbsp; [AI assistants](#-using-this-gem-with-ai-assistants) &nbsp;·&nbsp; [Development](#-development) &nbsp;·&nbsp; [Contributing](#-contributing) &nbsp;·&nbsp; [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 &amp; 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 &amp; 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.25"
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.25.0.gem # install locally
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)**. ⭐️ stars and 🍴 forks appreciated.
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
- all.to_a.count do |record|
110
- next false if record.anonymized?
114
+ count = 0
115
+ relation.find_each do |record|
116
+ next if record.anonymized?
111
117
 
112
- record.anonymize!
113
- true
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
- 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
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
- payload[field] = self.class.type_for_attribute(field.to_s).cast(value)
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
- self.class.counter_cacheable_rules.each do |rule|
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
- self.class.counter_cacheable_rules.each do |rule|
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
- self.class.counter_cacheable_rules.each { |rule| counter_cacheable_apply_update(rule) }
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 counter_cacheable_apply_update(rule)
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
- counter_cacheable_apply_same_parent(rule, new_fk, old_counted, new_counted)
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
- counter_cacheable_apply_reparent(rule, old_fk, new_fk, old_counted, new_counted)
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
- # Same parent — only a condition flip can change the count.
229
- def counter_cacheable_apply_same_parent(rule, parent_id, old_counted, new_counted)
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
- counter_cacheable_adjust(rule, parent_id, new_counted ? 1 : -1)
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
- # Foreign key changed settle the old parent and the new one independently.
236
- def counter_cacheable_apply_reparent(rule, old_fk, new_fk, old_counted, new_counted)
237
- counter_cacheable_adjust(rule, old_fk, -1) if old_fk && old_counted
238
- counter_cacheable_adjust(rule, new_fk, 1) if new_fk && new_counted
239
- end
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
- def counter_cacheable_adjust(rule, parent_id, delta)
242
- return if parent_id.nil?
251
+ counters[:touch] = true if group.any? { |adj| adj[:touch] }
252
+ klass.update_counters(parent_id, counters)
253
+ end
254
+ end
243
255
 
244
- counters = { rule[:count_column] => delta }
245
- counters[:touch] = true if rule[:touch]
246
- counter_cacheable_reflection(rule).klass.update_counters(parent_id, counters)
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
- unless subunit_to_unit.to_i.positive?
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. The
112
- # increment-until-free loop is a best-effort guard against pre-taken values;
113
- # a scoped unique index is the real concurrency guarantee.
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 a model missing it used to fail at
68
- # first save with an opaque friendly_id error instead of this
69
- # concern's clear ArgumentError.
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, scope].compact,
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
- # Uses an explicit `>=` rather than an endless range (`x..`): AR only
28
- # translates an endless range to a `>=` predicate on Rails 6.0+, but this
29
- # gem supports Rails >= 5.0.
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("#{connection.quote_column_name(soft_delete_field.to_s)} >= ?", duration.ago)
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
- all.to_a.count do |record|
69
- next false if record.deleted?
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
- true
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
- soft_deleted.to_a.count do |record|
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
- true
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
- field_config = field_options if field_config.nil? && field_options.any?
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
- # validate direction and must be :asc or :desc
96
- direction = :asc unless %i[asc desc].include?(direction)
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
- # extract key and value
103
- # when we call .first, we get the first key-value pair
104
- # Example: { position: :asc }.first => ["position", :asc]
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 stateable_perform_transition!(field, to, from, event)
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 must not contain the delimiter (default ",").
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
- pluck(taggable_field).flat_map { |raw| taggable_split(raw) }.uniq.sort
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.map { |t| taggable_clean(t) }.reject(&:blank?).uniq
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.map { |t| self.class.taggable_clean(t) }
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.map { |t| self.class.taggable_clean(t) }
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
- tag_list.include?(self.class.taggable_clean(tag))
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
- items = value.is_a?(Array) ? value : value.to_s.split(self.class.taggable_delimiter)
161
- items.map { |t| self.class.taggable_clean(t) }.reject(&:blank?).uniq
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.
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.25.0".freeze
2
+ VERSION = "1.26.0".freeze
3
3
  end
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.25.0
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-16 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack