concerns_on_rails 1.26.0 → 1.28.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 +168 -0
- data/README.md +174 -4
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +24 -1
- data/lib/concerns_on_rails/controllers/paginatable.rb +181 -32
- 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/support/link_header.rb +54 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f9aef0d5f95ffc23ef6213d5d95ea872a6146763d488a287a334db8be68b922
|
|
4
|
+
data.tar.gz: b364f4276149a31b1ce1f6efaf170435900f34188eb358035c10020567e8b51d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 025306bb013a418c8051db996ddd0fe6f2bf7ac7f657d22335639b5bcceecff8416e85c66ebde92a357656eeaba90f51a2993fab23232267af105e823cf28934
|
|
7
|
+
data.tar.gz: b0d98d45a2b189b43985e5d5548c3b905a161f2aa0df9022d251a2788b62524360bc002d2981e7e9801a10e4b0f7aa4a7b1d6b0ee5adb4298edeef89a07a8467
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,173 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.0 (2026-09-06)
|
|
4
|
+
|
|
5
|
+
Pagination is the theme: the four-PR Paginatable stack (#40, #45, #62, #83)
|
|
6
|
+
from the September enhancement loop. Paginatable now paginates in-memory
|
|
7
|
+
collections, both paginators emit RFC 8288 `Link` headers, results that were
|
|
8
|
+
already paginated upstream can pass `total:`, and the page / per-page parameter
|
|
9
|
+
names (including JSON:API's `page[number]` / `page[size]`) are configurable.
|
|
10
|
+
Controller-only: no new columns, migrations or runtime dependencies. 1303
|
|
11
|
+
examples, 0 failures.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Controllers::Paginatable**: `paginated` / `pagination_meta` accept in-memory
|
|
15
|
+
collections — an `Array`, `Set`, `Range` or any other non-Hash `Enumerable` —
|
|
16
|
+
not only ActiveRecord relations. The collection is materialized once, the
|
|
17
|
+
total is its size and the current page comes back as an `Array` with the same
|
|
18
|
+
`X-Total-Count` / `X-Page` / `X-Per-Page` / `X-Total-Pages` headers and
|
|
19
|
+
memoized meta. Relations still paginate in SQL. A `Hash`, `nil` or a
|
|
20
|
+
non-collection raises `ArgumentError` naming the class received. (#40)
|
|
21
|
+
- **Controllers::Paginatable / CursorPaginatable**: RFC 8288 `Link` response
|
|
22
|
+
header — `first`/`prev`/`next`/`last` page URLs for Paginatable, `next`
|
|
23
|
+
(+ `prev` in bidirectional mode, `first` once a cursor is in play) for
|
|
24
|
+
CursorPaginatable — rebuilt from the current request with every other query
|
|
25
|
+
param preserved and appended to any existing `Link` header. Opt out with
|
|
26
|
+
`link_header: false` on either macro. Backed by the new shared
|
|
27
|
+
`Support::LinkHeader`. (#45)
|
|
28
|
+
- **Controllers::Paginatable**: `paginated(collection, total:)` /
|
|
29
|
+
`pagination_meta(total:)` for collections that are already one page — an
|
|
30
|
+
external API's or search service's page N plus its total: nothing is sliced
|
|
31
|
+
or counted, and `total` drives the `X-*` headers and the `Link` header.
|
|
32
|
+
`total:` must be a non-negative Integer. (#62)
|
|
33
|
+
- **Controllers::Paginatable**: `paginate_by page_param:` / `per_page_param:`
|
|
34
|
+
(a top-level name or a nested path such as `%i[page number]`) and
|
|
35
|
+
`style: :jsonapi` (`?page[number]=&page[size]=`); explicit `*_param:` options
|
|
36
|
+
win over the style, anything else raises at declaration. Readers dig the path
|
|
37
|
+
through `ScalarParam`, so a scalar where a Hash is expected falls back to the
|
|
38
|
+
default instead of raising. The `Link` header URLs are rebuilt under the
|
|
39
|
+
configured names (nested paths Rack-encoded, sibling keys preserved). (#83)
|
|
40
|
+
|
|
41
|
+
### Notes
|
|
42
|
+
The `Link` header is **on by default**, so every non-empty paginated response
|
|
43
|
+
grows by one header. Its URLs are built from `request.base_url` + `request.path`,
|
|
44
|
+
i.e. whatever host Rails sees — behind a proxy configure `trusted_proxies` /
|
|
45
|
+
forwarded headers, or pass `link_header: false`. The `X-*` headers are
|
|
46
|
+
unchanged either way, and the existing `?page=&per_page=` behaviour is
|
|
47
|
+
byte-for-byte the same when no `page_param:` / `per_page_param:` / `style:` is
|
|
48
|
+
given.
|
|
49
|
+
|
|
50
|
+
### Internal
|
|
51
|
+
- New `Support::LinkHeader` (autoloaded): `available?(controller)` — a request
|
|
52
|
+
exposing `base_url` / `path` / `query_parameters` (the dependency-free
|
|
53
|
+
`FakeController` has none, so emission is silently skipped); `url_for(request,
|
|
54
|
+
drop:, **overrides)` — Rack nested-query encoding, nested params survive, Hash
|
|
55
|
+
override values deep-stringified, `nil` / `drop:` removes a key;
|
|
56
|
+
`append(response, rels)` — never clobbers an existing `Link`.
|
|
57
|
+
- `Paginatable.paginate_by` moved into `module ClassMethods` alongside its new
|
|
58
|
+
private helpers (RuboCop scope rule; the Stateable precedent).
|
|
59
|
+
- Development dependencies: simplecov `~> 1.1`, sqlite3 `~> 2.9.6`, rubocop
|
|
60
|
+
`~> 1.89`; GitHub Actions `checkout` v7, `configure-pages` v6, `deploy-pages`
|
|
61
|
+
v5, `upload-pages-artifact` v5. The lockfile now resolves `permittable` 0.2.0
|
|
62
|
+
(the `~> 0.1` runtime pin is unchanged).
|
|
63
|
+
|
|
64
|
+
## 1.27.0 (2026-08-29)
|
|
65
|
+
|
|
66
|
+
Scope-name collisions finally have an escape hatch on the eight concerns whose
|
|
67
|
+
generated scope names can collide (Activatable, Expirable, Lockable, Stateable
|
|
68
|
+
and Anonymizable already had it; Publishable, SoftDeletable and Schedulable
|
|
69
|
+
join them here), and the 1.22 batch-operation contract reaches five more
|
|
70
|
+
concerns. No new columns, migrations or dependencies. 1257 examples, 0
|
|
71
|
+
failures.
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
- **Models::Publishable / SoftDeletable / Schedulable**: `prefix:`/`suffix:` on
|
|
75
|
+
`publishable_by` / `soft_deletable_by` / `schedulable_by` rename the generated
|
|
76
|
+
scopes, so a model can include SoftDeletable (`.active`) alongside Activatable
|
|
77
|
+
or Expirable (also `.active`) without one silently clobbering the other. With
|
|
78
|
+
no affix passed the scope names, default scopes and emitted SQL are unchanged.
|
|
79
|
+
`prefix: true` (use the configured field name), previously honoured only by
|
|
80
|
+
Stateable, now works on every affixing concern.
|
|
81
|
+
- **Models::Publishable**: `publish_all` / `unpublish_all`. `publish_all` targets
|
|
82
|
+
every not-currently-published row — *including scheduled ones*, whose future
|
|
83
|
+
timestamp it overwrites; chain the draft scope (`Post.draft.publish_all`) to
|
|
84
|
+
narrow it, which composes because the predicate is built against the current
|
|
85
|
+
relation rather than routed through the field-unscoping `unpublished` scope.
|
|
86
|
+
The flip side: on a model with `default_scope: true` the relation is already
|
|
87
|
+
narrowed to published rows, so a bare `Post.publish_all` matches nothing —
|
|
88
|
+
chain `.draft` or `.unpublished` (both unscope the column themselves). Both
|
|
89
|
+
verbs respect the relation, return an Integer count, and run in a
|
|
90
|
+
transaction.
|
|
91
|
+
- **Models::Expirable**: `expire_all(time = Time.zone.now)`.
|
|
92
|
+
- **Models::Activatable**: `activate_all` / `deactivate_all`.
|
|
93
|
+
- **Models::Lockable**: `unlock_expired` — clears `locked_at` and zeroes the
|
|
94
|
+
attempts counter on every row whose `unlock_in` window has elapsed, mirroring
|
|
95
|
+
`unlock_access!`. Returns 0 without querying when `unlock_in` is nil.
|
|
96
|
+
- **Models::Stateable**: `transition_all(event)` — runs one declared transition
|
|
97
|
+
across the relation, skipping (not failing) records the guard rejects.
|
|
98
|
+
Deliberately has no single-UPDATE fast path: the per-record path runs
|
|
99
|
+
validations through `update!` and a bulk UPDATE would skip them.
|
|
100
|
+
|
|
101
|
+
### Notes
|
|
102
|
+
Validation semantics of the new batch verbs (`publish_all`, `unpublish_all`,
|
|
103
|
+
`expire_all`, `activate_all`, `deactivate_all`, `unlock_expired`): each
|
|
104
|
+
collapses to a **single `UPDATE`** — one SQL statement for the whole batch —
|
|
105
|
+
only when the host model declares **no validations** (and has overridden
|
|
106
|
+
none of the concern's hooks/bang methods; see the per-concern docs for the
|
|
107
|
+
exact method list). "No validations" means neither `validates` /
|
|
108
|
+
`validates_with` **nor** a custom `validate :method` / `validate do … end` —
|
|
109
|
+
the latter registers only a validate callback and leaves `validators` empty,
|
|
110
|
+
so it is detected through `_validate_callbacks` rather than `validators`.
|
|
111
|
+
It also means **no association carrying the default autosave validation**: a
|
|
112
|
+
bare `has_many`/`has_one` registers a `validate_associated_records_*` callback,
|
|
113
|
+
so in practice most models with associations take the streaming per-record
|
|
114
|
+
path. That is deliberate — the gate errs toward the path that honours the
|
|
115
|
+
rollback contract — but it means the single-`UPDATE` optimisation applies to
|
|
116
|
+
simple models, not to every model that merely omits `validates`.
|
|
117
|
+
`unlock_expired` is exempt from the validations check because
|
|
118
|
+
`unlock_access!` writes via `update_columns`, which always skips validations,
|
|
119
|
+
so its two paths are already equivalent. On a model that declares any
|
|
120
|
+
validation, five of these verbs (`publish_all`, `unpublish_all`, `expire_all`,
|
|
121
|
+
`activate_all`, `deactivate_all`) instead stream the relation record-by-record
|
|
122
|
+
inside a transaction, calling the same guarded bang/update method a single
|
|
123
|
+
record would use; a record that fails to save raises
|
|
124
|
+
`ActiveRecord::RecordNotSaved` and rolls the **entire batch** back — nothing
|
|
125
|
+
partially commits.
|
|
126
|
+
|
|
127
|
+
The single-`UPDATE` path writes `updated_at` (`updated_on` too, when present)
|
|
128
|
+
alongside the concern's own column whenever the model has that column and
|
|
129
|
+
`record_timestamps` is on, so the fast and slow paths agree — the same thing
|
|
130
|
+
Rails' `touch_all` and `update_counters(touch:)` do. `unlock_expired` is
|
|
131
|
+
exempt here as well: it mirrors `unlock_access!`, which writes via
|
|
132
|
+
`update_columns` and deliberately does not touch timestamps.
|
|
133
|
+
|
|
134
|
+
`transition_all` also always takes the per-record path, regardless of
|
|
135
|
+
validators — for the same underlying reason (validations must run) — but its
|
|
136
|
+
failure mode is different, not the same: the per-record path calls the
|
|
137
|
+
guarded `<event>!`, which calls `update!`, and `update!` raises
|
|
138
|
+
`ActiveRecord::RecordInvalid` **directly** on a validation failure. That
|
|
139
|
+
exception propagates straight out of the batch loop, never reaching the
|
|
140
|
+
`RecordNotSaved` branch the other five verbs use. Code that rescues
|
|
141
|
+
`RecordNotSaved` around a `transition_all` call will not catch a failed row —
|
|
142
|
+
rescue `RecordInvalid` there instead.
|
|
143
|
+
|
|
144
|
+
One residual, deliberate divergence survives on the fast path: like every
|
|
145
|
+
`*_all` method in Rails, the single-UPDATE path does not fire host-defined
|
|
146
|
+
`before_save`/`after_save` callbacks (only the concern's own
|
|
147
|
+
`before_*`/`after_*` lifecycle hooks are checked when deciding whether the
|
|
148
|
+
fast path applies at all). If your model relies on `before_save`/`after_save`
|
|
149
|
+
for side effects, either add a validation (forcing the slow, per-record path)
|
|
150
|
+
or call the bang method in a loop.
|
|
151
|
+
|
|
152
|
+
### Internal
|
|
153
|
+
- New `Support::Affix` (affixed-name computation, `prefix: true` normalization,
|
|
154
|
+
and the guarded scope capture/retirement used by the three newly affixable
|
|
155
|
+
concerns) replaces six duplicated implementations across Activatable,
|
|
156
|
+
Expirable, Lockable, Anonymizable, Stateable and Storable.
|
|
157
|
+
- New `Support::BatchOps`: the whole fast-path safety decision
|
|
158
|
+
(`fast_path?` = hooks/bang methods unoverridden AND no declared validations,
|
|
159
|
+
detected through both `validators` and `_validate_callbacks`), the
|
|
160
|
+
ownership-only half (`unoverridden?`, for the two concerns whose per-record
|
|
161
|
+
path writes via `update_column(s)` and so needs no validations gate), the
|
|
162
|
+
`updated_at`/`updated_on` bulk-write payload helper (`with_timestamps`), and
|
|
163
|
+
the transactional `find_each` runner. SoftDeletable's `soft_delete_all` /
|
|
164
|
+
`restore_all` now route through it, so the contract has one definition.
|
|
165
|
+
- Retiring a default-named scope is guarded three ways — the name must have been
|
|
166
|
+
recorded by the concern, be owned by the class's own singleton, and still be
|
|
167
|
+
the exact method captured — so a model's own override survives and a parent's
|
|
168
|
+
scopes are never removed from a subclass (that case raises with a pointer to
|
|
169
|
+
the parent).
|
|
170
|
+
|
|
3
171
|
## 1.26.0 (2026-08-24)
|
|
4
172
|
|
|
5
173
|
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).
|
|
@@ -1321,6 +1455,30 @@ class ArticlesController < ApplicationController
|
|
|
1321
1455
|
end
|
|
1322
1456
|
```
|
|
1323
1457
|
|
|
1458
|
+
**Arrays and other Enumerables work too.** Results that never touched the database — an
|
|
1459
|
+
external API response, a loaded association, a hand-built list of Structs — get the same
|
|
1460
|
+
slicing, headers and `pagination_meta`. Relations still paginate in SQL (`LIMIT`/`OFFSET`);
|
|
1461
|
+
an in-memory collection is sliced in Ruby and comes back as an `Array`:
|
|
1462
|
+
|
|
1463
|
+
```ruby
|
|
1464
|
+
def search
|
|
1465
|
+
render json: paginated(ExternalCatalog.search(params[:q])) # Array in, current page out
|
|
1466
|
+
end
|
|
1467
|
+
```
|
|
1468
|
+
|
|
1469
|
+
Anything answering `limit`/`offset` is treated as a relation; any other non-`Hash` `Enumerable`
|
|
1470
|
+
(`Array`, `Set`, `Range`, `Enumerator` — consumed once) is materialized and sliced. A `Hash`,
|
|
1471
|
+
`nil` or a non-collection raises `ArgumentError` (call `.to_a` to paginate a Hash's pairs).
|
|
1472
|
+
|
|
1473
|
+
**Already paginated upstream?** When an external API or search service hands you page N and the
|
|
1474
|
+
total it counted, pass `total:` — nothing is sliced, limited or counted; the collection comes back
|
|
1475
|
+
as-is and `total` drives `X-Total-Count`, `X-Total-Pages` and the `Link` header:
|
|
1476
|
+
|
|
1477
|
+
```ruby
|
|
1478
|
+
result = Catalog.search(params[:q], page: params[:page], per_page: params[:per_page])
|
|
1479
|
+
render_success(data: paginated(result.hits, total: result.total_hits), meta: pagination_meta)
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1324
1482
|
**URL params**
|
|
1325
1483
|
|
|
1326
1484
|
| Param | Default | Notes |
|
|
@@ -1328,7 +1486,19 @@ end
|
|
|
1328
1486
|
| `?page=` | `1` | Page numbers below 1 are clamped to 1 |
|
|
1329
1487
|
| `?per_page=` | `25` | Capped at `max_per_page` (default 200) |
|
|
1330
1488
|
|
|
1331
|
-
|
|
1489
|
+
Rename them, or speak JSON:API — the `Link` header URLs follow whatever you pick:
|
|
1490
|
+
|
|
1491
|
+
```ruby
|
|
1492
|
+
paginate_by page_param: :p, per_page_param: :limit # ?p=2&limit=10
|
|
1493
|
+
paginate_by style: :jsonapi # ?page[number]=2&page[size]=10
|
|
1494
|
+
paginate_by page_param: %i[paging page], per_page_param: %i[paging per] # any nested path
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
**Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`, and an RFC 8288 `Link`
|
|
1498
|
+
header with `first` / `prev` / `next` / `last` URLs rebuilt from the current request (other query params
|
|
1499
|
+
preserved; `prev`/`next` only when such a page exists; nothing for an empty collection) — the GitHub
|
|
1500
|
+
convention, so clients follow links instead of computing page numbers. Appended to any `Link` header
|
|
1501
|
+
already set (Deprecatable, CDN hints). `paginate_by link_header: false` turns it off.
|
|
1332
1502
|
|
|
1333
1503
|
---
|
|
1334
1504
|
|
|
@@ -1356,7 +1526,7 @@ end
|
|
|
1356
1526
|
| `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the cap) |
|
|
1357
1527
|
| `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
|
|
1358
1528
|
|
|
1359
|
-
**Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`.
|
|
1529
|
+
**Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`. Plus an RFC 8288 `Link` header: `rel="next"` carries the next-cursor URL, `rel="prev"` the prev-cursor URL (bidirectional), `rel="first"` the current URL with the cursor dropped (once a cursor is in play); `per_page` and the order preset are preserved. `cursor_paginate_by link_header: false` turns it off.
|
|
1360
1530
|
|
|
1361
1531
|
**Notes**
|
|
1362
1532
|
- The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen **in code** (never from params) and should be `NOT NULL` (a NULL boundary value raises rather than silently dropping rows).
|
|
@@ -1952,9 +2122,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
1952
2122
|
|
|
1953
2123
|
```sh
|
|
1954
2124
|
bundle install # install dev dependencies
|
|
1955
|
-
bundle exec rspec # run the test suite (1,
|
|
2125
|
+
bundle exec rspec # run the test suite (1,303 examples)
|
|
1956
2126
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1957
|
-
gem install ./concerns_on_rails-1.
|
|
2127
|
+
gem install ./concerns_on_rails-1.28.0.gem # install locally
|
|
1958
2128
|
|
|
1959
2129
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
1960
2130
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -2,6 +2,7 @@ require "active_support/concern"
|
|
|
2
2
|
require "concerns_on_rails/support/error_envelope"
|
|
3
3
|
require "concerns_on_rails/support/scalar_param"
|
|
4
4
|
require "json"
|
|
5
|
+
require "concerns_on_rails/support/link_header"
|
|
5
6
|
require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
|
|
6
7
|
|
|
7
8
|
module ConcernsOnRails
|
|
@@ -141,6 +142,7 @@ module ConcernsOnRails
|
|
|
141
142
|
class_attribute :cursor_paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
|
|
142
143
|
class_attribute :cursor_paginatable_bidirectional, default: false
|
|
143
144
|
class_attribute :cursor_paginatable_predicate, default: :auto
|
|
145
|
+
class_attribute :cursor_paginatable_link_header, default: true
|
|
144
146
|
|
|
145
147
|
# Real controllers (anything with ActiveSupport::Rescuable) get the 400
|
|
146
148
|
# handlers automatically; bare objects let the errors propagate.
|
|
@@ -161,7 +163,7 @@ module ConcernsOnRails
|
|
|
161
163
|
# max_per_page: 0 (or negative) disables the per_page cap.
|
|
162
164
|
def cursor_paginate_by(order: nil, order_presets: nil, default_preset: nil, order_param: :order,
|
|
163
165
|
per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE,
|
|
164
|
-
bidirectional: false, predicate: :auto)
|
|
166
|
+
bidirectional: false, predicate: :auto, link_header: true)
|
|
165
167
|
self.cursor_paginatable_order = order && CursorPaginatable.normalize_order!(order)
|
|
166
168
|
self.cursor_paginatable_order_presets = order_presets && CursorPaginatable.normalize_presets!(order_presets)
|
|
167
169
|
self.cursor_paginatable_default_preset =
|
|
@@ -171,6 +173,7 @@ module ConcernsOnRails
|
|
|
171
173
|
self.cursor_paginatable_max_per_page = max_per_page.to_i
|
|
172
174
|
self.cursor_paginatable_bidirectional = bidirectional ? true : false
|
|
173
175
|
self.cursor_paginatable_predicate = CursorPaginatable.validate_predicate!(predicate)
|
|
176
|
+
self.cursor_paginatable_link_header = link_header ? true : false
|
|
174
177
|
end
|
|
175
178
|
end
|
|
176
179
|
|
|
@@ -506,11 +509,31 @@ module ConcernsOnRails
|
|
|
506
509
|
response.set_header("X-Count", meta[:count].to_s)
|
|
507
510
|
response.set_header("X-Has-More", meta[:has_more].to_s)
|
|
508
511
|
response.set_header("X-Next-Cursor", meta[:next_cursor]) if meta[:next_cursor]
|
|
512
|
+
apply_cursor_pagination_links(meta)
|
|
509
513
|
return unless meta.key?(:has_prev)
|
|
510
514
|
|
|
511
515
|
response.set_header("X-Has-Prev", meta[:has_prev].to_s)
|
|
512
516
|
response.set_header("X-Prev-Cursor", meta[:prev_cursor]) if meta[:prev_cursor]
|
|
513
517
|
end
|
|
518
|
+
|
|
519
|
+
# RFC 8288 Link: rel="next" carries the X-Next-Cursor token, rel="prev"
|
|
520
|
+
# the X-Prev-Cursor one (bidirectional mode), rel="first" is the current
|
|
521
|
+
# URL with the cursor dropped — emitted once a cursor is in play. Other
|
|
522
|
+
# query params (per_page, the order preset) are preserved. Skipped when
|
|
523
|
+
# disabled or without a real request.
|
|
524
|
+
def apply_cursor_pagination_links(meta)
|
|
525
|
+
return unless self.class.cursor_paginatable_link_header
|
|
526
|
+
return unless ConcernsOnRails::Support::LinkHeader.available?(self)
|
|
527
|
+
|
|
528
|
+
cursor_url = ->(token) { ConcernsOnRails::Support::LinkHeader.url_for(request, cursor: token) }
|
|
529
|
+
raw_cursor = params[:cursor]
|
|
530
|
+
ConcernsOnRails::Support::LinkHeader.append(
|
|
531
|
+
response,
|
|
532
|
+
first: ConcernsOnRails::Support::ScalarParam.scalar?(raw_cursor) && !raw_cursor.to_s.empty? ? cursor_url.call(nil) : nil,
|
|
533
|
+
prev: meta[:prev_cursor] && cursor_url.call(meta[:prev_cursor]),
|
|
534
|
+
next: meta[:next_cursor] && cursor_url.call(meta[:next_cursor])
|
|
535
|
+
)
|
|
536
|
+
end
|
|
514
537
|
end
|
|
515
538
|
end
|
|
516
539
|
end
|