concerns_on_rails 1.26.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 +107 -0
- data/README.md +136 -2
- data/lib/concerns_on_rails/models/activatable.rb +54 -5
- data/lib/concerns_on_rails/models/anonymizable.rb +7 -3
- 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/publishable.rb +146 -37
- data/lib/concerns_on_rails/models/schedulable.rb +63 -29
- data/lib/concerns_on_rails/models/soft_deletable.rb +77 -51
- data/lib/concerns_on_rails/models/stateable.rb +35 -5
- data/lib/concerns_on_rails/models/storable.rb +2 -1
- 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/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,112 @@
|
|
|
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
|
+
|
|
3
110
|
## 1.26.0 (2026-08-24)
|
|
4
111
|
|
|
5
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.
|
data/README.md
CHANGED
|
@@ -358,6 +358,58 @@ publishable_by :published_at, default_scope: true
|
|
|
358
358
|
# Article.unscoped reaches everything
|
|
359
359
|
```
|
|
360
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
|
+
|
|
361
413
|
**Notes**
|
|
362
414
|
- "Published" means `published_at` is set **and** in the past — so future-dated posts stay unpublished until their time arrives.
|
|
363
415
|
- No `default_scope` is added by default; chain `.published` explicitly (or opt in with `default_scope: true`).
|
|
@@ -412,6 +464,21 @@ collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-del
|
|
|
412
464
|
predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
|
|
413
465
|
relation — purge trash with `User.soft_deleted.delete_all` instead.
|
|
414
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
|
+
|
|
415
482
|
**Lifecycle hooks** — override these methods on the model:
|
|
416
483
|
|
|
417
484
|
```ruby
|
|
@@ -512,6 +579,18 @@ promo.reschedule!(starts_at: 1.day.from_now,
|
|
|
512
579
|
ends_at: 2.days.from_now)
|
|
513
580
|
```
|
|
514
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
|
+
|
|
515
594
|
**Notes**
|
|
516
595
|
- Boundary semantics: **inclusive start, exclusive end** — active at exactly `starts_at`, not at exactly `ends_at`.
|
|
517
596
|
- A `nil` end means "never expires"; a `nil` start means "not yet started".
|
|
@@ -552,6 +631,21 @@ token.extend_expiry!(by: 1.day) # pushes expiry forward
|
|
|
552
631
|
- If `expires_at` is `nil` or in the past → new value is `now + by`
|
|
553
632
|
- If `expires_at` is still in the future → `by` is added to the existing value
|
|
554
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
|
+
|
|
555
649
|
**Custom field name**
|
|
556
650
|
|
|
557
651
|
```ruby
|
|
@@ -659,6 +753,23 @@ Subscription.active # WHERE active = TRUE
|
|
|
659
753
|
Subscription.inactive # WHERE active = FALSE OR active IS NULL
|
|
660
754
|
```
|
|
661
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
|
+
|
|
662
773
|
**Notes**
|
|
663
774
|
- `NULL` is treated as inactive (same convention as most apps' "unset = off").
|
|
664
775
|
- The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
|
|
@@ -805,6 +916,18 @@ article.may_publish? # => true (guard check without raising)
|
|
|
805
916
|
article.transition_to!(:archived) # generic move to any declared state
|
|
806
917
|
```
|
|
807
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
|
+
|
|
808
931
|
**Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
|
|
809
932
|
|
|
810
933
|
```ruby
|
|
@@ -1094,10 +1217,21 @@ user.reset_failed_attempts! # call on successful login
|
|
|
1094
1217
|
user.lock_access! # manual lock (hooks: before/after_lock)
|
|
1095
1218
|
user.unlock_access! # manual unlock (hooks: before/after_unlock)
|
|
1096
1219
|
User.locked / User.unlocked # expiry-aware scopes
|
|
1220
|
+
|
|
1221
|
+
User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
|
|
1097
1222
|
```
|
|
1098
1223
|
|
|
1099
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).
|
|
1100
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
|
+
|
|
1101
1235
|
**Notes**
|
|
1102
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.
|
|
1103
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).
|
|
@@ -1952,9 +2086,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
1952
2086
|
|
|
1953
2087
|
```sh
|
|
1954
2088
|
bundle install # install dev dependencies
|
|
1955
|
-
bundle exec rspec # run the test suite (1,
|
|
2089
|
+
bundle exec rspec # run the test suite (1,245 examples)
|
|
1956
2090
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1957
|
-
gem install ./concerns_on_rails-1.
|
|
2091
|
+
gem install ./concerns_on_rails-1.27.0.gem # install locally
|
|
1958
2092
|
|
|
1959
2093
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
1960
2094
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/column_guard"
|
|
3
|
+
require "concerns_on_rails/support/affix"
|
|
4
|
+
require "concerns_on_rails/support/batch_ops"
|
|
3
5
|
|
|
4
6
|
module ConcernsOnRails
|
|
5
7
|
module Models
|
|
@@ -26,25 +28,72 @@ module ConcernsOnRails
|
|
|
26
28
|
|
|
27
29
|
included do
|
|
28
30
|
class_attribute :activatable_field, instance_accessor: false, default: DEFAULT_FIELD
|
|
31
|
+
class_attribute :activatable_scope_names, instance_accessor: false,
|
|
32
|
+
default: { active: :active, inactive: :inactive }.freeze
|
|
29
33
|
end
|
|
30
34
|
|
|
31
|
-
class_methods do
|
|
35
|
+
class_methods do # rubocop:disable Metrics/BlockLength
|
|
32
36
|
include ConcernsOnRails::Support::ColumnGuard
|
|
33
37
|
|
|
34
38
|
def activatable_by(field = DEFAULT_FIELD, prefix: nil, suffix: nil)
|
|
35
39
|
self.activatable_field = field.to_sym
|
|
36
40
|
ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field, types: :boolean)
|
|
37
41
|
|
|
42
|
+
prefix = ConcernsOnRails::Support::Affix.normalize(prefix, default: activatable_field)
|
|
43
|
+
suffix = ConcernsOnRails::Support::Affix.normalize(suffix, default: activatable_field)
|
|
44
|
+
self.activatable_scope_names = {
|
|
45
|
+
active: ConcernsOnRails::Support::Affix.name(:active, prefix: prefix, suffix: suffix),
|
|
46
|
+
inactive: ConcernsOnRails::Support::Affix.name(:inactive, prefix: prefix, suffix: suffix)
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
38
49
|
# Affix the scope names so two concerns that each define `.active`
|
|
39
50
|
# (e.g. SoftDeletable / Expirable) can coexist on one model.
|
|
40
|
-
scope
|
|
41
|
-
scope
|
|
51
|
+
scope activatable_scope_names[:active], -> { where(activatable_field => true) }
|
|
52
|
+
scope activatable_scope_names[:inactive], -> { where(activatable_field => [false, nil]) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Activate every inactive record in the relation; returns the count.
|
|
56
|
+
def activate_all
|
|
57
|
+
inactive = all.public_send(activatable_scope_names.fetch(:inactive))
|
|
58
|
+
if activatable_batch_fast_path?(:activate!)
|
|
59
|
+
return inactive.update_all(
|
|
60
|
+
ConcernsOnRails::Support::BatchOps.with_timestamps(self, activatable_field => true)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
ConcernsOnRails::Support::BatchOps.run(
|
|
65
|
+
inactive,
|
|
66
|
+
label: "ConcernsOnRails::Models::Activatable",
|
|
67
|
+
message: "failed to activate record",
|
|
68
|
+
&:activate!
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Deactivate every active record in the relation; returns the count.
|
|
73
|
+
def deactivate_all
|
|
74
|
+
active = all.public_send(activatable_scope_names.fetch(:active))
|
|
75
|
+
if activatable_batch_fast_path?(:deactivate!)
|
|
76
|
+
return active.update_all(
|
|
77
|
+
ConcernsOnRails::Support::BatchOps.with_timestamps(self, activatable_field => false)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
ConcernsOnRails::Support::BatchOps.run(
|
|
82
|
+
active,
|
|
83
|
+
label: "ConcernsOnRails::Models::Activatable",
|
|
84
|
+
message: "failed to deactivate record",
|
|
85
|
+
&:deactivate!
|
|
86
|
+
)
|
|
42
87
|
end
|
|
43
88
|
|
|
44
89
|
private
|
|
45
90
|
|
|
46
|
-
|
|
47
|
-
|
|
91
|
+
# Whether the single-UPDATE fast path is safe — the whole decision
|
|
92
|
+
# (bang method unoverridden AND the model declares no validations,
|
|
93
|
+
# plus why) lives in Support::BatchOps.fast_path?. Activatable defines
|
|
94
|
+
# no lifecycle hooks, so the bang method is the only one to check.
|
|
95
|
+
def activatable_batch_fast_path?(method)
|
|
96
|
+
ConcernsOnRails::Support::BatchOps.fast_path?(self, ConcernsOnRails::Models::Activatable, method)
|
|
48
97
|
end
|
|
49
98
|
end
|
|
50
99
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/column_guard"
|
|
3
|
+
require "concerns_on_rails/support/affix"
|
|
3
4
|
require "digest"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
|
|
@@ -152,9 +153,12 @@ module ConcernsOnRails
|
|
|
152
153
|
return if anonymizable_scopes_defined || anonymizable_stamp.nil?
|
|
153
154
|
|
|
154
155
|
self.anonymizable_scopes_defined = true
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
scope
|
|
156
|
+
prefix = ConcernsOnRails::Support::Affix.normalize(prefix, default: anonymizable_stamp)
|
|
157
|
+
suffix = ConcernsOnRails::Support::Affix.normalize(suffix, default: anonymizable_stamp)
|
|
158
|
+
scope ConcernsOnRails::Support::Affix.name(:anonymized, prefix: prefix, suffix: suffix),
|
|
159
|
+
-> { where.not(anonymizable_stamp => nil) }
|
|
160
|
+
scope ConcernsOnRails::Support::Affix.name(:not_anonymized, prefix: prefix, suffix: suffix),
|
|
161
|
+
-> { where(anonymizable_stamp => nil) }
|
|
158
162
|
end
|
|
159
163
|
end
|
|
160
164
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/column_guard"
|
|
3
|
+
require "concerns_on_rails/support/affix"
|
|
4
|
+
require "concerns_on_rails/support/batch_ops"
|
|
3
5
|
|
|
4
6
|
module ConcernsOnRails
|
|
5
7
|
module Models
|
|
@@ -10,9 +12,12 @@ module ConcernsOnRails
|
|
|
10
12
|
|
|
11
13
|
included do
|
|
12
14
|
class_attribute :expirable_field, instance_accessor: false, default: DEFAULT_FIELD
|
|
15
|
+
class_attribute :expirable_scope_names, instance_accessor: false,
|
|
16
|
+
default: { active: :active, expired: :expired,
|
|
17
|
+
expiring_within: :expiring_within }.freeze
|
|
13
18
|
end
|
|
14
19
|
|
|
15
|
-
class_methods do
|
|
20
|
+
class_methods do # rubocop:disable Metrics/BlockLength
|
|
16
21
|
include ConcernsOnRails::Support::ColumnGuard
|
|
17
22
|
|
|
18
23
|
# Configure the expiry column.
|
|
@@ -25,29 +30,58 @@ module ConcernsOnRails
|
|
|
25
30
|
define_expirable_scopes(prefix, suffix)
|
|
26
31
|
end
|
|
27
32
|
|
|
33
|
+
# Expire every currently-active record in the relation. Returns the
|
|
34
|
+
# Integer count. Expirable defines no lifecycle hooks, so this is a
|
|
35
|
+
# single UPDATE unless the model overrode `expire!` or declares
|
|
36
|
+
# validations (see Support::BatchOps.fast_path?).
|
|
37
|
+
def expire_all(time = Time.zone.now)
|
|
38
|
+
active = all.public_send(expirable_scope_names.fetch(:active))
|
|
39
|
+
if expirable_batch_fast_path?
|
|
40
|
+
return active.update_all(
|
|
41
|
+
ConcernsOnRails::Support::BatchOps.with_timestamps(self, expirable_field => time)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ConcernsOnRails::Support::BatchOps.run(
|
|
46
|
+
active,
|
|
47
|
+
label: "ConcernsOnRails::Models::Expirable",
|
|
48
|
+
message: "failed to expire record"
|
|
49
|
+
) { |record| record.expire!(time) }
|
|
50
|
+
end
|
|
51
|
+
|
|
28
52
|
private
|
|
29
53
|
|
|
54
|
+
# Whether the single-UPDATE fast path is safe — the whole decision
|
|
55
|
+
# (bang method unoverridden AND the model declares no validations,
|
|
56
|
+
# plus why) lives in Support::BatchOps.fast_path?. Expirable defines
|
|
57
|
+
# no lifecycle hooks, so `expire!` is the only method to check.
|
|
58
|
+
def expirable_batch_fast_path?
|
|
59
|
+
ConcernsOnRails::Support::BatchOps.fast_path?(self, ConcernsOnRails::Models::Expirable, :expire!)
|
|
60
|
+
end
|
|
61
|
+
|
|
30
62
|
# Scopes live here (not in `included do`) so their names can be affixed —
|
|
31
63
|
# letting Expirable's `.active`/`.expired` coexist with the same-named
|
|
32
64
|
# scopes from SoftDeletable / Activatable on a single model.
|
|
33
65
|
def define_expirable_scopes(prefix, suffix)
|
|
34
|
-
|
|
66
|
+
prefix = ConcernsOnRails::Support::Affix.normalize(prefix, default: expirable_field)
|
|
67
|
+
suffix = ConcernsOnRails::Support::Affix.normalize(suffix, default: expirable_field)
|
|
68
|
+
self.expirable_scope_names = %i[active expired expiring_within].to_h do |base|
|
|
69
|
+
[base, ConcernsOnRails::Support::Affix.name(base, prefix: prefix, suffix: suffix)]
|
|
70
|
+
end.freeze
|
|
71
|
+
|
|
72
|
+
scope expirable_scope_names[:active], lambda {
|
|
35
73
|
column = arel_table[expirable_field]
|
|
36
74
|
where(column.eq(nil).or(column.gt(Time.zone.now)))
|
|
37
75
|
}
|
|
38
|
-
scope
|
|
76
|
+
scope expirable_scope_names[:expired], lambda {
|
|
39
77
|
where(arel_table[expirable_field].lteq(Time.zone.now))
|
|
40
78
|
}
|
|
41
|
-
scope
|
|
79
|
+
scope expirable_scope_names[:expiring_within], lambda { |duration|
|
|
42
80
|
column = arel_table[expirable_field]
|
|
43
81
|
now = Time.zone.now
|
|
44
82
|
where(column.gt(now)).where(column.lteq(now + duration))
|
|
45
83
|
}
|
|
46
84
|
end
|
|
47
|
-
|
|
48
|
-
def expirable_scope_name(base, prefix, suffix)
|
|
49
|
-
[prefix, base, suffix].compact.join("_").to_sym
|
|
50
|
-
end
|
|
51
85
|
end
|
|
52
86
|
|
|
53
87
|
def active?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
2
|
require "concerns_on_rails/support/column_guard"
|
|
3
|
+
require "concerns_on_rails/support/affix"
|
|
4
|
+
require "concerns_on_rails/support/batch_ops"
|
|
3
5
|
|
|
4
6
|
module ConcernsOnRails
|
|
5
7
|
module Models
|
|
@@ -59,6 +61,8 @@ module ConcernsOnRails
|
|
|
59
61
|
class_attribute :lockable_locked_at_field, instance_accessor: false, default: DEFAULT_LOCKED_AT_FIELD
|
|
60
62
|
class_attribute :lockable_max_attempts, instance_accessor: false, default: DEFAULT_MAX_ATTEMPTS
|
|
61
63
|
class_attribute :lockable_unlock_in, instance_accessor: false, default: nil
|
|
64
|
+
class_attribute :lockable_scope_names, instance_accessor: false,
|
|
65
|
+
default: { locked: :locked, unlocked: :unlocked }.freeze
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
module ClassMethods
|
|
@@ -81,6 +85,39 @@ module ConcernsOnRails
|
|
|
81
85
|
define_lockable_scopes(prefix, suffix)
|
|
82
86
|
end
|
|
83
87
|
|
|
88
|
+
# Unlock every row whose lock window has fully elapsed, clearing
|
|
89
|
+
# locked_at and zeroing the attempts counter exactly as
|
|
90
|
+
# unlock_access! does. Returns the Integer count.
|
|
91
|
+
#
|
|
92
|
+
# Nothing expires when unlock_in is nil (manual unlock only), so that
|
|
93
|
+
# case returns 0 without touching the database. The boundary instant
|
|
94
|
+
# counts as expired, matching lock_expired? and the scopes.
|
|
95
|
+
def unlock_expired
|
|
96
|
+
unlock_in = lockable_unlock_in
|
|
97
|
+
return 0 unless unlock_in
|
|
98
|
+
|
|
99
|
+
locked_field = lockable_locked_at_field
|
|
100
|
+
attempts_field = lockable_attempts_field
|
|
101
|
+
# `lteq` on a NULL locked_at is NULL, so never-locked rows are
|
|
102
|
+
# excluded without an extra predicate.
|
|
103
|
+
expired = all.where(arel_table[locked_field].lteq(Time.zone.now - unlock_in))
|
|
104
|
+
|
|
105
|
+
# Ownership-only check (no validations gate, and no updated_at on the
|
|
106
|
+
# bulk write): `unlock_access!` writes via update_columns, which
|
|
107
|
+
# already skips validations and timestamps, so the two paths agree.
|
|
108
|
+
if ConcernsOnRails::Support::BatchOps.unoverridden?(self, ConcernsOnRails::Models::Lockable,
|
|
109
|
+
:before_unlock, :after_unlock, :unlock_access!)
|
|
110
|
+
return expired.update_all(locked_field => nil, attempts_field => 0)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
ConcernsOnRails::Support::BatchOps.run(
|
|
114
|
+
expired,
|
|
115
|
+
label: LABEL,
|
|
116
|
+
message: "failed to unlock record",
|
|
117
|
+
&:unlock_access!
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
84
121
|
private
|
|
85
122
|
|
|
86
123
|
def validate_lockable!(attempts, locked_at, max_attempts:, unlock_in:)
|
|
@@ -109,7 +146,14 @@ module ConcernsOnRails
|
|
|
109
146
|
# configuration and compute the cutoff in Ruby at call time, so the
|
|
110
147
|
# predicate stays portable (no adapter-specific SQL date math).
|
|
111
148
|
def define_lockable_scopes(prefix, suffix)
|
|
112
|
-
|
|
149
|
+
prefix = ConcernsOnRails::Support::Affix.normalize(prefix, default: lockable_locked_at_field)
|
|
150
|
+
suffix = ConcernsOnRails::Support::Affix.normalize(suffix, default: lockable_locked_at_field)
|
|
151
|
+
self.lockable_scope_names = {
|
|
152
|
+
locked: ConcernsOnRails::Support::Affix.name(:locked, prefix: prefix, suffix: suffix),
|
|
153
|
+
unlocked: ConcernsOnRails::Support::Affix.name(:unlocked, prefix: prefix, suffix: suffix)
|
|
154
|
+
}.freeze
|
|
155
|
+
|
|
156
|
+
scope lockable_scope_names[:locked], lambda {
|
|
113
157
|
field = lockable_locked_at_field
|
|
114
158
|
if lockable_unlock_in
|
|
115
159
|
where(arel_table[field].gt(Time.zone.now - lockable_unlock_in))
|
|
@@ -117,7 +161,7 @@ module ConcernsOnRails
|
|
|
117
161
|
where.not(field => nil)
|
|
118
162
|
end
|
|
119
163
|
}
|
|
120
|
-
scope
|
|
164
|
+
scope lockable_scope_names[:unlocked], lambda {
|
|
121
165
|
field = lockable_locked_at_field
|
|
122
166
|
if lockable_unlock_in
|
|
123
167
|
column = arel_table[field]
|
|
@@ -128,10 +172,6 @@ module ConcernsOnRails
|
|
|
128
172
|
}
|
|
129
173
|
end
|
|
130
174
|
|
|
131
|
-
def lockable_scope_name(base, prefix, suffix)
|
|
132
|
-
[prefix, base, suffix].compact.join("_").to_sym
|
|
133
|
-
end
|
|
134
|
-
|
|
135
175
|
def positive_integer_or_nil?(value)
|
|
136
176
|
value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
137
177
|
end
|