concerns_on_rails 1.25.0 → 1.27.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 +133 -0
- data/README.md +279 -55
- data/lib/concerns_on_rails/models/activatable.rb +54 -5
- data/lib/concerns_on_rails/models/anonymizable.rb +49 -16
- data/lib/concerns_on_rails/models/counter_cacheable.rb +41 -28
- data/lib/concerns_on_rails/models/expirable.rb +42 -8
- data/lib/concerns_on_rails/models/lockable.rb +46 -6
- data/lib/concerns_on_rails/models/monetizable.rb +6 -1
- data/lib/concerns_on_rails/models/publishable.rb +156 -38
- data/lib/concerns_on_rails/models/schedulable.rb +63 -29
- 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 +77 -44
- data/lib/concerns_on_rails/models/sortable.rb +21 -7
- data/lib/concerns_on_rails/models/stateable.rb +62 -7
- data/lib/concerns_on_rails/models/storable.rb +2 -1
- data/lib/concerns_on_rails/models/taggable.rb +31 -8
- data/lib/concerns_on_rails/support/affix.rb +80 -0
- data/lib/concerns_on_rails/support/batch_ops.rb +103 -0
- data/lib/concerns_on_rails/support/sequence_calculator.rb +0 -4
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b06bf25720ff6c5c3a243efdb7b0d575351d50b8a9f33ae4778c1354816075bb
|
|
4
|
+
data.tar.gz: 01dc0ff521bee7b5c74c9f30ddee414932441d2ceff84a15af1d9ae8e6d30ccc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37a533852c90cc1876519bd8b52fd07f165bfcfb02efca47bd1ed9d3ff86de94457755d57f9b986d244674a29992cead1b43698de7e3480e92276a0b9c9f1eda
|
|
7
|
+
data.tar.gz: 3651f6a175bf1a1e203bbe8353a9fc8b7a2c6457dd19f73e89e31fa108a8e238f36a7004aeffface605800b2838f6bb935694ef823a65dc0be943763bfd31293
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,138 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.27.0 (2026-08-29)
|
|
4
|
+
|
|
5
|
+
Scope-name collisions finally have an escape hatch on the eight concerns whose
|
|
6
|
+
generated scope names can collide (Activatable, Expirable, Lockable, Stateable
|
|
7
|
+
and Anonymizable already had it; Publishable, SoftDeletable and Schedulable
|
|
8
|
+
join them here), and the 1.22 batch-operation contract reaches five more
|
|
9
|
+
concerns. No new columns, migrations or dependencies. 1257 examples, 0
|
|
10
|
+
failures.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Models::Publishable / SoftDeletable / Schedulable**: `prefix:`/`suffix:` on
|
|
14
|
+
`publishable_by` / `soft_deletable_by` / `schedulable_by` rename the generated
|
|
15
|
+
scopes, so a model can include SoftDeletable (`.active`) alongside Activatable
|
|
16
|
+
or Expirable (also `.active`) without one silently clobbering the other. With
|
|
17
|
+
no affix passed the scope names, default scopes and emitted SQL are unchanged.
|
|
18
|
+
`prefix: true` (use the configured field name), previously honoured only by
|
|
19
|
+
Stateable, now works on every affixing concern.
|
|
20
|
+
- **Models::Publishable**: `publish_all` / `unpublish_all`. `publish_all` targets
|
|
21
|
+
every not-currently-published row — *including scheduled ones*, whose future
|
|
22
|
+
timestamp it overwrites; chain the draft scope (`Post.draft.publish_all`) to
|
|
23
|
+
narrow it, which composes because the predicate is built against the current
|
|
24
|
+
relation rather than routed through the field-unscoping `unpublished` scope.
|
|
25
|
+
The flip side: on a model with `default_scope: true` the relation is already
|
|
26
|
+
narrowed to published rows, so a bare `Post.publish_all` matches nothing —
|
|
27
|
+
chain `.draft` or `.unpublished` (both unscope the column themselves). Both
|
|
28
|
+
verbs respect the relation, return an Integer count, and run in a
|
|
29
|
+
transaction.
|
|
30
|
+
- **Models::Expirable**: `expire_all(time = Time.zone.now)`.
|
|
31
|
+
- **Models::Activatable**: `activate_all` / `deactivate_all`.
|
|
32
|
+
- **Models::Lockable**: `unlock_expired` — clears `locked_at` and zeroes the
|
|
33
|
+
attempts counter on every row whose `unlock_in` window has elapsed, mirroring
|
|
34
|
+
`unlock_access!`. Returns 0 without querying when `unlock_in` is nil.
|
|
35
|
+
- **Models::Stateable**: `transition_all(event)` — runs one declared transition
|
|
36
|
+
across the relation, skipping (not failing) records the guard rejects.
|
|
37
|
+
Deliberately has no single-UPDATE fast path: the per-record path runs
|
|
38
|
+
validations through `update!` and a bulk UPDATE would skip them.
|
|
39
|
+
|
|
40
|
+
### Notes
|
|
41
|
+
Validation semantics of the new batch verbs (`publish_all`, `unpublish_all`,
|
|
42
|
+
`expire_all`, `activate_all`, `deactivate_all`, `unlock_expired`): each
|
|
43
|
+
collapses to a **single `UPDATE`** — one SQL statement for the whole batch —
|
|
44
|
+
only when the host model declares **no validations** (and has overridden
|
|
45
|
+
none of the concern's hooks/bang methods; see the per-concern docs for the
|
|
46
|
+
exact method list). "No validations" means neither `validates` /
|
|
47
|
+
`validates_with` **nor** a custom `validate :method` / `validate do … end` —
|
|
48
|
+
the latter registers only a validate callback and leaves `validators` empty,
|
|
49
|
+
so it is detected through `_validate_callbacks` rather than `validators`.
|
|
50
|
+
It also means **no association carrying the default autosave validation**: a
|
|
51
|
+
bare `has_many`/`has_one` registers a `validate_associated_records_*` callback,
|
|
52
|
+
so in practice most models with associations take the streaming per-record
|
|
53
|
+
path. That is deliberate — the gate errs toward the path that honours the
|
|
54
|
+
rollback contract — but it means the single-`UPDATE` optimisation applies to
|
|
55
|
+
simple models, not to every model that merely omits `validates`.
|
|
56
|
+
`unlock_expired` is exempt from the validations check because
|
|
57
|
+
`unlock_access!` writes via `update_columns`, which always skips validations,
|
|
58
|
+
so its two paths are already equivalent. On a model that declares any
|
|
59
|
+
validation, five of these verbs (`publish_all`, `unpublish_all`, `expire_all`,
|
|
60
|
+
`activate_all`, `deactivate_all`) instead stream the relation record-by-record
|
|
61
|
+
inside a transaction, calling the same guarded bang/update method a single
|
|
62
|
+
record would use; a record that fails to save raises
|
|
63
|
+
`ActiveRecord::RecordNotSaved` and rolls the **entire batch** back — nothing
|
|
64
|
+
partially commits.
|
|
65
|
+
|
|
66
|
+
The single-`UPDATE` path writes `updated_at` (`updated_on` too, when present)
|
|
67
|
+
alongside the concern's own column whenever the model has that column and
|
|
68
|
+
`record_timestamps` is on, so the fast and slow paths agree — the same thing
|
|
69
|
+
Rails' `touch_all` and `update_counters(touch:)` do. `unlock_expired` is
|
|
70
|
+
exempt here as well: it mirrors `unlock_access!`, which writes via
|
|
71
|
+
`update_columns` and deliberately does not touch timestamps.
|
|
72
|
+
|
|
73
|
+
`transition_all` also always takes the per-record path, regardless of
|
|
74
|
+
validators — for the same underlying reason (validations must run) — but its
|
|
75
|
+
failure mode is different, not the same: the per-record path calls the
|
|
76
|
+
guarded `<event>!`, which calls `update!`, and `update!` raises
|
|
77
|
+
`ActiveRecord::RecordInvalid` **directly** on a validation failure. That
|
|
78
|
+
exception propagates straight out of the batch loop, never reaching the
|
|
79
|
+
`RecordNotSaved` branch the other five verbs use. Code that rescues
|
|
80
|
+
`RecordNotSaved` around a `transition_all` call will not catch a failed row —
|
|
81
|
+
rescue `RecordInvalid` there instead.
|
|
82
|
+
|
|
83
|
+
One residual, deliberate divergence survives on the fast path: like every
|
|
84
|
+
`*_all` method in Rails, the single-UPDATE path does not fire host-defined
|
|
85
|
+
`before_save`/`after_save` callbacks (only the concern's own
|
|
86
|
+
`before_*`/`after_*` lifecycle hooks are checked when deciding whether the
|
|
87
|
+
fast path applies at all). If your model relies on `before_save`/`after_save`
|
|
88
|
+
for side effects, either add a validation (forcing the slow, per-record path)
|
|
89
|
+
or call the bang method in a loop.
|
|
90
|
+
|
|
91
|
+
### Internal
|
|
92
|
+
- New `Support::Affix` (affixed-name computation, `prefix: true` normalization,
|
|
93
|
+
and the guarded scope capture/retirement used by the three newly affixable
|
|
94
|
+
concerns) replaces six duplicated implementations across Activatable,
|
|
95
|
+
Expirable, Lockable, Anonymizable, Stateable and Storable.
|
|
96
|
+
- New `Support::BatchOps`: the whole fast-path safety decision
|
|
97
|
+
(`fast_path?` = hooks/bang methods unoverridden AND no declared validations,
|
|
98
|
+
detected through both `validators` and `_validate_callbacks`), the
|
|
99
|
+
ownership-only half (`unoverridden?`, for the two concerns whose per-record
|
|
100
|
+
path writes via `update_column(s)` and so needs no validations gate), the
|
|
101
|
+
`updated_at`/`updated_on` bulk-write payload helper (`with_timestamps`), and
|
|
102
|
+
the transactional `find_each` runner. SoftDeletable's `soft_delete_all` /
|
|
103
|
+
`restore_all` now route through it, so the contract has one definition.
|
|
104
|
+
- Retiring a default-named scope is guarded three ways — the name must have been
|
|
105
|
+
recorded by the concern, be owned by the class's own singleton, and still be
|
|
106
|
+
the exact method captured — so a model's own override survives and a parent's
|
|
107
|
+
scopes are never removed from a subclass (that case raises with a pointer to
|
|
108
|
+
the parent).
|
|
109
|
+
|
|
110
|
+
## 1.26.0 (2026-08-24)
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
- **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.
|
|
116
|
+
- **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).
|
|
117
|
+
- **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.
|
|
118
|
+
- **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.
|
|
119
|
+
- **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).
|
|
120
|
+
|
|
121
|
+
### Changed
|
|
122
|
+
- **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.
|
|
123
|
+
- **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.
|
|
124
|
+
- **Models::SoftDeletable**: `deleted_within` emits a table-qualified Arel predicate, so the scope stays unambiguous inside joins against tables sharing the column name.
|
|
125
|
+
|
|
126
|
+
### Performance
|
|
127
|
+
- **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.
|
|
128
|
+
- **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.
|
|
129
|
+
- **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).
|
|
130
|
+
- **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.
|
|
131
|
+
- **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.
|
|
132
|
+
|
|
133
|
+
### Internal
|
|
134
|
+
- 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).
|
|
135
|
+
|
|
3
136
|
## 1.25.0 (2026-08-16)
|
|
4
137
|
|
|
5
138
|
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:
|
|
@@ -308,6 +358,58 @@ publishable_by :published_at, default_scope: true
|
|
|
308
358
|
# Article.unscoped reaches everything
|
|
309
359
|
```
|
|
310
360
|
|
|
361
|
+
**Bulk operations**
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
Post.draft.publish_all # publishes every not-currently-published post; returns the count
|
|
365
|
+
Post.published.unpublish_all # unpublishes every currently-published post; returns the count
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Both respect the current relation, return an Integer count, and run in a transaction — a
|
|
369
|
+
record that fails to save raises `ActiveRecord::RecordNotSaved` and rolls the whole batch
|
|
370
|
+
back. With no overridden `before_publish`/`after_publish`/`before_unpublish`/`after_unpublish`/
|
|
371
|
+
`publish!`/`unpublish!` and no validations on the model — neither `validates`/`validates_with`,
|
|
372
|
+
a custom `validate :method`, nor an association's autosave validation (a bare `has_many`
|
|
373
|
+
registers one, so most models with associations take the streaming path) — both collapse to a
|
|
374
|
+
single `UPDATE`, which bumps `updated_at`
|
|
375
|
+
exactly as the per-record path does; otherwise they stream per record through
|
|
376
|
+
`publish!`/`unpublish!` so validations still run.
|
|
377
|
+
|
|
378
|
+
`publish_all` targets every not-currently-published row — **including scheduled ones**, whose
|
|
379
|
+
future `published_at` it overwrites — so chain `.draft` (`Post.draft.publish_all`) to exclude
|
|
380
|
+
them. Its predicate composes with your own constraint on the publish column, so that chain
|
|
381
|
+
really does leave scheduled rows alone. The flip side of composing: with `default_scope: true`
|
|
382
|
+
the relation is already narrowed to published rows, so a bare `Post.publish_all` matches
|
|
383
|
+
nothing — chain `.draft` or `.unpublished` first (both unscope the column themselves, so the
|
|
384
|
+
chain resolves to the rows you mean).
|
|
385
|
+
|
|
386
|
+
**Scope-name collisions**
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
publishable_by :published_at, prefix: :article # => Article.article_published / .article_draft
|
|
390
|
+
publishable_by :published_at, suffix: :posts # => Article.published_posts / .draft_posts
|
|
391
|
+
publishable_by :published_at, prefix: true # => Article.published_at_published / ...
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`prefix:`/`suffix:` rename every scope `publishable_by` generates, so a model can include
|
|
395
|
+
Publishable alongside another concern that would otherwise generate a same-named scope
|
|
396
|
+
(SoftDeletable and Activatable also define `.active`, for instance) without one clobbering
|
|
397
|
+
the other. With no affix passed, scope names, the optional default scope, and the emitted
|
|
398
|
+
SQL are all unchanged.
|
|
399
|
+
|
|
400
|
+
`prefix:`/`suffix:` mean three different things across the gem, depending on the concern:
|
|
401
|
+
- **Scope-name affix** (renames generated scopes) — Activatable, Expirable, Lockable,
|
|
402
|
+
Stateable, Anonymizable, Publishable, SoftDeletable, Schedulable.
|
|
403
|
+
- **Accessor-name affix** (renames generated reader/writer methods) — Storable.
|
|
404
|
+
- **A literal string prepended to the generated value itself** — Sequenceable's `prefix:`
|
|
405
|
+
(e.g. `"INV-"`), unrelated to scope/method naming.
|
|
406
|
+
|
|
407
|
+
Passing `true` for a scope- or accessor-name affix means "use the configured field name"
|
|
408
|
+
(`publishable_by :published_at, prefix: true` → `published_at_published`/`published_at_draft`).
|
|
409
|
+
|
|
410
|
+
Unrelated to all three: Searchable's `match: :prefix` is a LIKE-match mode (`term%`), not a
|
|
411
|
+
naming affix.
|
|
412
|
+
|
|
311
413
|
**Notes**
|
|
312
414
|
- "Published" means `published_at` is set **and** in the past — so future-dated posts stay unpublished until their time arrives.
|
|
313
415
|
- No `default_scope` is added by default; chain `.published` explicitly (or opt in with `default_scope: true`).
|
|
@@ -362,6 +464,21 @@ collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-del
|
|
|
362
464
|
predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
|
|
363
465
|
relation — purge trash with `User.soft_deleted.delete_all` instead.
|
|
364
466
|
|
|
467
|
+
**Scope-name collisions**
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
soft_deletable_by :deleted_at, prefix: :account # => .account_active / .account_soft_deleted / ...
|
|
471
|
+
soft_deletable_by :deleted_at, suffix: :records # => .active_records / .soft_deleted_records / ...
|
|
472
|
+
soft_deletable_by :deleted_at, prefix: true # => .deleted_at_active / ...
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`prefix:`/`suffix:` rename every scope `soft_deletable_by` generates (`active`,
|
|
476
|
+
`without_deleted`, `soft_deleted`, `only_deleted`, `with_deleted`, `deleted_within`), so a
|
|
477
|
+
model can combine SoftDeletable with another concern that also defines `.active` (Activatable,
|
|
478
|
+
Expirable) without a collision. `prefix: true` uses the configured field name. With no affix
|
|
479
|
+
passed, scope names, the default scope, and the emitted SQL are unchanged. See the
|
|
480
|
+
Publishable section above for how `prefix:`/`suffix:` differ across the gem.
|
|
481
|
+
|
|
365
482
|
**Lifecycle hooks** — override these methods on the model:
|
|
366
483
|
|
|
367
484
|
```ruby
|
|
@@ -462,6 +579,18 @@ promo.reschedule!(starts_at: 1.day.from_now,
|
|
|
462
579
|
ends_at: 2.days.from_now)
|
|
463
580
|
```
|
|
464
581
|
|
|
582
|
+
**Scope-name collisions**
|
|
583
|
+
|
|
584
|
+
```ruby
|
|
585
|
+
schedulable_by prefix: :promo # => .promo_current / .promo_upcoming / .promo_expired / .promo_active_at
|
|
586
|
+
schedulable_by suffix: :window # => .current_window / .upcoming_window / .expired_window / .active_at_window
|
|
587
|
+
schedulable_by prefix: true # => .starts_at_current / ... (the configured starts_at/ends_at field)
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
`prefix:`/`suffix:` rename every scope `schedulable_by` generates (`active_at`, `current`,
|
|
591
|
+
`upcoming`, `expired`). With no affix passed, scope names and the emitted SQL are unchanged.
|
|
592
|
+
See the Publishable section above for how `prefix:`/`suffix:` differ across the gem.
|
|
593
|
+
|
|
465
594
|
**Notes**
|
|
466
595
|
- Boundary semantics: **inclusive start, exclusive end** — active at exactly `starts_at`, not at exactly `ends_at`.
|
|
467
596
|
- A `nil` end means "never expires"; a `nil` start means "not yet started".
|
|
@@ -502,6 +631,21 @@ token.extend_expiry!(by: 1.day) # pushes expiry forward
|
|
|
502
631
|
- If `expires_at` is `nil` or in the past → new value is `now + by`
|
|
503
632
|
- If `expires_at` is still in the future → `by` is added to the existing value
|
|
504
633
|
|
|
634
|
+
**Bulk operations**
|
|
635
|
+
|
|
636
|
+
```ruby
|
|
637
|
+
ApiToken.expiring_within(1.day).expire_all # => 12
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
`expire_all(time = Time.zone.now)` expires every currently-active record in the relation and
|
|
641
|
+
returns the Integer count, in a transaction. With `expire!` unoverridden and no validations on
|
|
642
|
+
the model — neither `validates`/`validates_with`, a custom `validate :method`, nor an
|
|
643
|
+
association's autosave validation (a bare `has_many` registers one, so most models with
|
|
644
|
+
associations take the streaming path) — it collapses
|
|
645
|
+
to a single `UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise it
|
|
646
|
+
streams per record through `expire!` so validations still run, and a record that fails to save
|
|
647
|
+
raises `ActiveRecord::RecordNotSaved` and rolls the whole batch back.
|
|
648
|
+
|
|
505
649
|
**Custom field name**
|
|
506
650
|
|
|
507
651
|
```ruby
|
|
@@ -609,6 +753,23 @@ Subscription.active # WHERE active = TRUE
|
|
|
609
753
|
Subscription.inactive # WHERE active = FALSE OR active IS NULL
|
|
610
754
|
```
|
|
611
755
|
|
|
756
|
+
**Bulk operations**
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
Subscription.inactive.activate_all # => 12
|
|
760
|
+
Subscription.active.deactivate_all # => 3
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
Both target the relation, return an Integer count, and run in a transaction. With
|
|
764
|
+
`activate!`/`deactivate!` unoverridden and no validations on the model — neither
|
|
765
|
+
`validates`/`validates_with`, a custom `validate :method`, nor an association's autosave
|
|
766
|
+
validation (a bare `has_many` registers one, so most models with associations take the
|
|
767
|
+
streaming path) — they collapse to a single
|
|
768
|
+
`UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise they stream
|
|
769
|
+
per record so validations still run, and a record that fails to save raises
|
|
770
|
+
`ActiveRecord::RecordNotSaved` and rolls the whole batch back. `toggle_active!`'s row lock has
|
|
771
|
+
no batch analogue.
|
|
772
|
+
|
|
612
773
|
**Notes**
|
|
613
774
|
- `NULL` is treated as inactive (same convention as most apps' "unset = off").
|
|
614
775
|
- The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
|
|
@@ -755,6 +916,18 @@ article.may_publish? # => true (guard check without raising)
|
|
|
755
916
|
article.transition_to!(:archived) # generic move to any declared state
|
|
756
917
|
```
|
|
757
918
|
|
|
919
|
+
**Bulk operations**
|
|
920
|
+
|
|
921
|
+
```ruby
|
|
922
|
+
Article.draft.transition_all(:publish) # => 12 — runs :publish across every eligible row
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
Returns the Integer count of records transitioned, running in a transaction; records the
|
|
926
|
+
guard rejects are skipped (not errors). Unlike every other batch verb in this gem,
|
|
927
|
+
`transition_all` has **no single-UPDATE fast path** — it always streams per record through
|
|
928
|
+
the guarded `<event>!` method, because that path runs validations via `update!` while a bulk
|
|
929
|
+
`update_all` would silently skip them.
|
|
930
|
+
|
|
758
931
|
**Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
|
|
759
932
|
|
|
760
933
|
```ruby
|
|
@@ -1044,10 +1217,21 @@ user.reset_failed_attempts! # call on successful login
|
|
|
1044
1217
|
user.lock_access! # manual lock (hooks: before/after_lock)
|
|
1045
1218
|
user.unlock_access! # manual unlock (hooks: before/after_unlock)
|
|
1046
1219
|
User.locked / User.unlocked # expiry-aware scopes
|
|
1220
|
+
|
|
1221
|
+
User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
|
|
1047
1222
|
```
|
|
1048
1223
|
|
|
1049
1224
|
**Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
|
|
1050
1225
|
|
|
1226
|
+
**Bulk operations**
|
|
1227
|
+
|
|
1228
|
+
`unlock_expired` clears `locked_at` and zeroes the attempts counter on every row whose
|
|
1229
|
+
`locked_at + unlock_in` has passed, exactly as `unlock_access!` does — returns the Integer
|
|
1230
|
+
count, runs in a transaction. Returns `0` without querying when `unlock_in` is `nil` (manual
|
|
1231
|
+
unlock only). Because `unlock_access!` persists via `update_columns` (which already skips
|
|
1232
|
+
validations), the single-`UPDATE` fast path and the per-record path are equivalent here —
|
|
1233
|
+
unlike Publishable/Expirable/Activatable, this one has no validators gate.
|
|
1234
|
+
|
|
1051
1235
|
**Notes**
|
|
1052
1236
|
- The increment is SQL-side (`COALESCE(attempts, 0) + 1` via `update_counters`), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting.
|
|
1053
1237
|
- Expiry is **lazy**: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next `unlock_access!` or failed attempt (quietly there — no unlock hooks fire from a failed login).
|
|
@@ -1879,13 +2063,35 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1879
2063
|
|
|
1880
2064
|
---
|
|
1881
2065
|
|
|
2066
|
+
## 🤖 Using this gem with AI assistants
|
|
2067
|
+
|
|
2068
|
+
Working with Claude, Cursor, Copilot, or another AI coding agent? The docs are AI-ready — every page
|
|
2069
|
+
is plain Markdown, fetchable without JavaScript:
|
|
2070
|
+
|
|
2071
|
+
- **[`llms.txt`](https://vsn2015.github.io/concerns_on_rails/llms.txt)** — a machine-readable index of
|
|
2072
|
+
every concern document, following the [llms.txt convention](https://llmstxt.org)
|
|
2073
|
+
- **[`llms-full.txt`](https://vsn2015.github.io/concerns_on_rails/llms-full.txt)** — all 43 concern docs
|
|
2074
|
+
concatenated into one ~400 KB plain-text file, for one-shot context loading
|
|
2075
|
+
- **Per-concern Markdown** — `https://vsn2015.github.io/concerns_on_rails/concerns/<slug>.md`
|
|
2076
|
+
(e.g. [`concerns/sluggable.md`](https://vsn2015.github.io/concerns_on_rails/concerns/sluggable.md))
|
|
2077
|
+
when your agent only needs one concern's docs
|
|
2078
|
+
- This README also ships **inside the packaged gem**, so an agent reading your bundle already has the
|
|
2079
|
+
full reference locally
|
|
2080
|
+
|
|
2081
|
+
Point your agent at `llms.txt` for an overview, or paste a single concern's `.md` URL for focused context.
|
|
2082
|
+
|
|
2083
|
+
---
|
|
2084
|
+
|
|
1882
2085
|
## 🛠️ Development
|
|
1883
2086
|
|
|
1884
2087
|
```sh
|
|
1885
2088
|
bundle install # install dev dependencies
|
|
1886
|
-
bundle exec rspec # run the test suite
|
|
2089
|
+
bundle exec rspec # run the test suite (1,245 examples)
|
|
1887
2090
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1888
|
-
gem install ./concerns_on_rails-1.
|
|
2091
|
+
gem install ./concerns_on_rails-1.27.0.gem # install locally
|
|
2092
|
+
|
|
2093
|
+
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2094
|
+
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
1889
2095
|
```
|
|
1890
2096
|
|
|
1891
2097
|
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 +2100,25 @@ The test suite uses an in-memory SQLite database and a lightweight `FakeControll
|
|
|
1894
2100
|
|
|
1895
2101
|
## 🤝 Contributing
|
|
1896
2102
|
|
|
1897
|
-
Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**.
|
|
2103
|
+
Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**.
|
|
2104
|
+
|
|
2105
|
+
- 🐛 [Open an issue](https://github.com/VSN2015/concerns_on_rails/issues) — a failing spec is the fastest path to a fix
|
|
2106
|
+
- 🔀 Send a PR — run `bundle exec rspec` first; every concern keeps its spec under `spec/concerns/`
|
|
2107
|
+
- 📖 Docs count too — each concern's page lives in [`docs/concerns/`](docs/concerns) and redeploys automatically
|
|
2108
|
+
- ⭐️ If this gem saved you an afternoon of boilerplate, a star helps other devs find it
|
|
2109
|
+
|
|
2110
|
+
---
|
|
2111
|
+
|
|
2112
|
+
## 🔗 Links
|
|
2113
|
+
|
|
2114
|
+
| Resource | Where |
|
|
2115
|
+
|----------|-------|
|
|
2116
|
+
| 📖 Documentation site | [vsn2015.github.io/concerns_on_rails](https://vsn2015.github.io/concerns_on_rails) |
|
|
2117
|
+
| 💎 Gem page — live download count, all released versions | [rubygems.org/gems/concerns_on_rails](https://rubygems.org/gems/concerns_on_rails) |
|
|
2118
|
+
| 📝 Changelog | [CHANGELOG.md](CHANGELOG.md) |
|
|
2119
|
+
| 🤖 AI/LLM docs index (`llms.txt`) | [vsn2015.github.io/concerns_on_rails/llms.txt](https://vsn2015.github.io/concerns_on_rails/llms.txt) |
|
|
2120
|
+
| 🐛 Issue tracker | [github.com/VSN2015/concerns_on_rails/issues](https://github.com/VSN2015/concerns_on_rails/issues) |
|
|
2121
|
+
| 🛂 Permittable (extracted sibling gem) | [github.com/VSN2015/permittable](https://github.com/VSN2015/permittable) |
|
|
1898
2122
|
|
|
1899
2123
|
---
|
|
1900
2124
|
|