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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7552b47dbc09add52b4bb8d519e8a8d4ed4e49590457114003fa83b6e9b7036e
4
- data.tar.gz: b03171261ba6e24865ce46ab0c1e77a234cfec514e45e50a2dd055e82b5da287
3
+ metadata.gz: b06bf25720ff6c5c3a243efdb7b0d575351d50b8a9f33ae4778c1354816075bb
4
+ data.tar.gz: 01dc0ff521bee7b5c74c9f30ddee414932441d2ceff84a15af1d9ae8e6d30ccc
5
5
  SHA512:
6
- metadata.gz: 461bbe636a2537cc5b60fe1eb5be3c080c115a3f119ca057a5f75d24dd54da44a6f5833a1f2baff6d2562d829fa77f98e7d1494e57cd8e1402b513f697025d61
7
- data.tar.gz: 046a10782bc2259cf8911610a13b71289aa77ce2a1164045fd524fc785b58cc6061756ea99fba3f147d6964145641f63e03ed50b8530656b9a7269a4b83ce2bf
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,173 examples)
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.26.0.gem # install locally
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 activatable_scope_name(:active, prefix, suffix), -> { where(activatable_field => true) }
41
- scope activatable_scope_name(:inactive, prefix, suffix), -> { where(activatable_field => [false, nil]) }
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
- def activatable_scope_name(base, prefix, suffix)
47
- [prefix, base, suffix].compact.join("_").to_sym
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
- affixed = ->(base) { [prefix, base, suffix].compact.join("_") }
156
- scope affixed.call("anonymized"), -> { where.not(anonymizable_stamp => nil) }
157
- scope affixed.call("not_anonymized"), -> { where(anonymizable_stamp => nil) }
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
- scope expirable_scope_name(:active, prefix, suffix), lambda {
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 expirable_scope_name(:expired, prefix, suffix), lambda {
76
+ scope expirable_scope_names[:expired], lambda {
39
77
  where(arel_table[expirable_field].lteq(Time.zone.now))
40
78
  }
41
- scope expirable_scope_name(:expiring_within, prefix, suffix), lambda { |duration|
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
- scope lockable_scope_name(:locked, prefix, suffix), lambda {
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 lockable_scope_name(:unlocked, prefix, suffix), lambda {
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