concerns_on_rails 1.28.1 → 1.28.3
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 +110 -0
- data/README.md +109 -17
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +66 -3
- data/lib/concerns_on_rails/controllers/error_handleable.rb +229 -28
- data/lib/concerns_on_rails/models/anonymizable.rb +1 -1
- data/lib/concerns_on_rails/models/sequenceable.rb +31 -6
- data/lib/concerns_on_rails/models/sluggable.rb +64 -1
- data/lib/concerns_on_rails/models/soft_deletable.rb +151 -16
- data/lib/concerns_on_rails/models/sortable.rb +65 -0
- data/lib/concerns_on_rails/support/column_guard.rb +38 -15
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3f06aad189272886038354ad98dfd58a84642d64e1313c4aac5d88e7d1ec90a
|
|
4
|
+
data.tar.gz: b38acca4cb5cd6a48496d0d4909da90782c14b6b71748af88b72df482a02ef62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98d77a84da148195bdd1c013054b12bd3b8c4f6ce2bf5b7ae8ee7e91c0a24b4d5d45e6540c139456e1d51b91afbfaed5cf68d9faf9859399cb4ec2b76ccdd90b
|
|
7
|
+
data.tar.gz: 6852cb66a64150139c9625d765cd3f724881a6b35a9e5920140713c21b69f148c956353d587765f3ad1eca6018b38de70b98aa8aa9c40006ad0a6919242607c8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.3 (2026-09-16)
|
|
4
|
+
|
|
5
|
+
Three merged PRs from the September loop (#41, #43, #52), shipped as a patch at
|
|
6
|
+
the maintainer's request: a SoftDeletable bug fix (`restore_all` /
|
|
7
|
+
`really_destroy_all` dropped the caller's own predicate on the soft-delete
|
|
8
|
+
column), a ColumnGuard change (every missing column reported in one error with
|
|
9
|
+
one migration command) and an additive SoftDeletable `cascade:` option for
|
|
10
|
+
has_many / has_one dependents. No new migrations or runtime dependencies.
|
|
11
|
+
1396 examples, 0 failures.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Models::SoftDeletable**: `restore_all` and `really_destroy_all` now honour a
|
|
15
|
+
caller's predicate on the soft-delete column. Both used to `unscope` the
|
|
16
|
+
column outright to peel off the default scope's `deleted_at IS NULL`, which
|
|
17
|
+
also dropped `deleted_within(1.hour)` / `where(deleted_at: range)` /
|
|
18
|
+
`only_deleted` — so `User.deleted_within(1.hour).restore_all` restored the
|
|
19
|
+
whole trash can and `only_deleted.really_destroy_all` widened to the whole
|
|
20
|
+
relation. Only the default scope's own predicate is peeled now; predicates on
|
|
21
|
+
other columns (a host model's own `default_scope` included) are untouched. The
|
|
22
|
+
scopes themselves still unscope the column: chain `soft_deleted.where(...)`,
|
|
23
|
+
not `where(...).soft_deleted`. (#41)
|
|
24
|
+
- **Models::Anonymizable**: the stamp column's migration hint now carries its
|
|
25
|
+
type (`anonymized_at:datetime`). (#43)
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- **Support::ColumnGuard**: a macro that finds several missing columns now
|
|
29
|
+
reports them all in one `ArgumentError` — `'street', 'city' and 'zip' do not
|
|
30
|
+
exist …` — with a single combined migration command
|
|
31
|
+
(`bin/rails generate migration AddAddressableColumnsToUsers street:string
|
|
32
|
+
city:string zip:string`) instead of failing boot once per column. Single-
|
|
33
|
+
column wording and generator name are unchanged. (#43)
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **Models::SoftDeletable**: `soft_deletable_by … cascade: %i[comments cover]`
|
|
37
|
+
soft-deletes has_many / has_one dependents with the record (same
|
|
38
|
+
transaction, same timestamp, through their own `soft_delete!` so hooks and
|
|
39
|
+
nested cascades run) and restores exactly those on `restore!` — a dependent
|
|
40
|
+
deleted independently earlier stays deleted. New `soft_delete!(at:)` keyword.
|
|
41
|
+
With a cascade configured, `soft_delete_all` / `restore_all` take the
|
|
42
|
+
per-record path. `belongs_to`, HABTM and `:through` are rejected at class
|
|
43
|
+
load; the target model must include SoftDeletable (checked at class load when
|
|
44
|
+
it already resolves, otherwise on the first cascade). (#52)
|
|
45
|
+
|
|
46
|
+
### Notes
|
|
47
|
+
`cascade:` is off by default — models without it behave exactly as before.
|
|
48
|
+
`restore!` matches cascaded dependents by the parent's exact `deleted_at`, so
|
|
49
|
+
give the columns `precision: 6` (the Rails 7 default) if two parents may be
|
|
50
|
+
deleted within one second. With a cascade configured the single-`UPDATE` fast
|
|
51
|
+
paths of `soft_delete_all` / `restore_all` are disabled (a bulk `UPDATE` cannot
|
|
52
|
+
follow associations). Anything matching `/does not exist/` on a one-column
|
|
53
|
+
ColumnGuard failure still matches — only the several-columns wording and
|
|
54
|
+
generator name changed. The README's "use instead" table no longer lists
|
|
55
|
+
association-cascade soft delete as a reason to reach for paranoia / discard.
|
|
56
|
+
|
|
57
|
+
## 1.28.2 (2026-09-11)
|
|
58
|
+
|
|
59
|
+
Six merged enhancement PRs from the September loop (#42, #84, #59 via #90, #80,
|
|
60
|
+
#81, #82), all additive and column-free: ErrorHandleable rescues 14 exceptions
|
|
61
|
+
instead of 3 and instruments every handled error, CursorPaginatable cursors can
|
|
62
|
+
be HMAC-signed, Sortable applies a drag-and-drop order in one UPDATE, Sluggable
|
|
63
|
+
gains slug candidates / word-boundary truncation / forced regeneration, and
|
|
64
|
+
Sequenceable can defer numbering until an invoice is finalized. No new
|
|
65
|
+
migrations or runtime dependencies. 1360 examples, 0 failures.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
- **Controllers::ErrorHandleable**: the exception map grows from 3 to 14 —
|
|
69
|
+
`ActiveModel::ValidationError`, `RecordNotSaved`, `RecordNotDestroyed` (422),
|
|
70
|
+
`StaleObjectError`, `RecordNotUnique`, `InvalidForeignKey` (409),
|
|
71
|
+
`UnpermittedParameters`, `BadRequest`, `ParseError` (400),
|
|
72
|
+
`InvalidAuthenticityToken` (422) and `UnknownFormat` (406). Statuses follow
|
|
73
|
+
Rails' `rescue_responses`; database/parser-level errors render generic
|
|
74
|
+
messages so SQL, schema names and request input never leak. New
|
|
75
|
+
`handle_errors only:/except:` macro trims the map without touching a host
|
|
76
|
+
`rescue_from`; `error_handleable_keys` lists the active keys. (#42)
|
|
77
|
+
- **Controllers::ErrorHandleable**: every handled error instruments
|
|
78
|
+
`handled_error.concerns_on_rails` (controller, action, code, status, message,
|
|
79
|
+
exception) via the public `on_handled_error(key, error, status:, message:)`
|
|
80
|
+
override point — report some codes to the error tracker, let the rest stay
|
|
81
|
+
quiet. (#84)
|
|
82
|
+
- **Controllers::CursorPaginatable**: `cursor_paginate_by … signed: true` (or a
|
|
83
|
+
String key / callable) appends a URL-safe HMAC-SHA256 to every cursor and
|
|
84
|
+
rejects tampered, mis-signed or unsigned tokens with the usual 400, so
|
|
85
|
+
clients can no longer hand-craft page positions. Unsigned stays the default.
|
|
86
|
+
(#59, merged via #90)
|
|
87
|
+
- **Sortable (model)**: `Model.reposition!(ids, missing: :append | :raise)`
|
|
88
|
+
applies a drag-and-drop id order as positions in one `UPDATE … CASE` inside
|
|
89
|
+
the current relation (unlisted rows appended or raised, foreign/duplicate ids
|
|
90
|
+
rejected, String ids cast, `:desc` lists inverted). Returns the count. (#80)
|
|
91
|
+
- **Sluggable**: `candidates:` (friendly_id slug candidates tried in order
|
|
92
|
+
before the uuid fallback; regeneration still keyed to the primary field),
|
|
93
|
+
`max_length:` (word-boundary truncation, suffix appended after) and
|
|
94
|
+
`regenerate_slug!` (rebuild from the current source even over a
|
|
95
|
+
hand-assigned slug). (#81)
|
|
96
|
+
- **Sequenceable**: `assign: :manual` defers numbering until `assign_<field>!`
|
|
97
|
+
(idempotent — `true` when assigned, `false` when already numbered; `save!`s
|
|
98
|
+
persisted records, leaves new ones for the caller's save), plus
|
|
99
|
+
`<field>_assigned?` and a `pending_<field>` scope. Numbering then follows
|
|
100
|
+
finalization order; `reset:` periods still come from `created_at`. (#82)
|
|
101
|
+
|
|
102
|
+
### Notes
|
|
103
|
+
Apps that include ErrorHandleable now get 11 more exceptions rescued into JSON
|
|
104
|
+
4xx responses. One that relied on, say, `StaleObjectError` propagating to its
|
|
105
|
+
error tracker should add `handle_errors except: :stale_object` (or `only:` the
|
|
106
|
+
original trio) — or override `on_handled_error` to report the codes it cares
|
|
107
|
+
about. Exceptions are registered by name, so a class a given Rails version
|
|
108
|
+
lacks is simply never matched. Turning on `signed:` invalidates in-flight
|
|
109
|
+
unsigned cursors (there is no mixed mode by design), so clients restart from
|
|
110
|
+
the first page. `reposition!` writes positions with `update_all` and therefore
|
|
111
|
+
bypasses acts_as_list's per-row callbacks by design.
|
|
112
|
+
|
|
3
113
|
## 1.28.1 (2026-09-07)
|
|
4
114
|
|
|
5
115
|
One additive Paginatable option (#89): `pagination_meta` can now publish the
|
data/README.md
CHANGED
|
@@ -146,9 +146,9 @@ across all 43 concerns — press <kbd>/</kbd> and type.
|
|
|
146
146
|
- **Twenty-six model concerns + sixteen controller concerns**, all production-ready
|
|
147
147
|
- **One include, one macro** — no boilerplate, no glue code
|
|
148
148
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
|
|
149
|
-
- **Schema-validated configuration** — every macro checks that the configured
|
|
149
|
+
- **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
|
|
150
150
|
- **Composable** — concerns are independent; mix and match per model
|
|
151
|
-
- **Tested like an app, not a snippet** — **1,
|
|
151
|
+
- **Tested like an app, not a snippet** — **1,396 RSpec examples** run against a real database on every CI build
|
|
152
152
|
- **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
|
|
153
153
|
|
|
154
154
|
---
|
|
@@ -275,6 +275,10 @@ sluggable_by :title, reserved_words: %w[new edit admin]
|
|
|
275
275
|
|
|
276
276
|
# Let Model.find accept a slug directly (not just the id)
|
|
277
277
|
sluggable_by :title, finders: true
|
|
278
|
+
sluggable_by :title, candidates: [:title, %i[title city]] # try "title", then "title-city", then friendly_id's uuid suffix on the first candidate
|
|
279
|
+
sluggable_by :title, max_length: 60 # truncate at a word boundary (uniqueness suffix added after)
|
|
280
|
+
|
|
281
|
+
page.regenerate_slug! # rebuild from the current source — even over a hand-assigned slug
|
|
278
282
|
Post.find("hello-world") # resolves by slug
|
|
279
283
|
```
|
|
280
284
|
|
|
@@ -282,6 +286,9 @@ Post.find("hello-world") # resolves by slug
|
|
|
282
286
|
- Schema must have a `slug` column (string).
|
|
283
287
|
- `history: true` requires a `friendly_id_slugs` table — generate with `rails generate friendly_id` or add a manual migration.
|
|
284
288
|
- `scope: :col` requires `col` to exist in the same table.
|
|
289
|
+
- `candidates:` takes friendly_id's shapes — a Symbol/String method, a Proc, or an Array of those joined with `-` — tried in order until one is free (all taken → the first candidate plus a uuid); the slug still regenerates only when the **primary** field changes (a candidate-only change doesn't churn the URL), and a NULL slug backfills through the candidates.
|
|
290
|
+
- `max_length:` truncates each candidate at the last `-` inside the limit (a single long word is hard-cut); friendly_id's conflict suffix is appended afterwards, so a colliding slug may exceed the limit — unlike friendly_id's own `slug_limit`, which squeezes the uuid inside it.
|
|
291
|
+
- `regenerate_slug!` is the escape hatch for the explicit-slug rule: it forces regeneration and saves (`save!`), keeping uniqueness handling.
|
|
285
292
|
- Falls back to `to_s` if the configured source field doesn't respond.
|
|
286
293
|
- Uses friendly_id's `:slugged` (+ optionally `:history`, `:scoped`) strategies under the hood.
|
|
287
294
|
|
|
@@ -301,6 +308,10 @@ end
|
|
|
301
308
|
Task.create!(name: "A")
|
|
302
309
|
Task.create!(name: "B")
|
|
303
310
|
Task.last.move_higher
|
|
311
|
+
|
|
312
|
+
# Save a drag-and-drop order in ONE UPDATE (CASE id WHEN …): the ids' order becomes their positions
|
|
313
|
+
Task.reposition!(params[:ids]) # => 12 — rows not listed are pushed after, in their current order
|
|
314
|
+
Task.where(list_id: 1).reposition!(ids, missing: :raise) # scoped; a partial list is an error
|
|
304
315
|
```
|
|
305
316
|
|
|
306
317
|
**Configuration**
|
|
@@ -314,8 +325,8 @@ sortable_by :position, add_new_at: :top # new rows insert at the top (a
|
|
|
314
325
|
```
|
|
315
326
|
|
|
316
327
|
**Notes**
|
|
317
|
-
- The configured field must exist as a column.
|
|
318
|
-
-
|
|
328
|
+
- The configured field must exist as a column; a direction other than `:asc` / `:desc` raises at declaration.
|
|
329
|
+
- `reposition!` runs inside the current relation (`Task.where(list_id: 1)` — the same set acts_as_list's `scope:` would use), rejects ids outside it and duplicates before writing anything, coerces String ids from params, and on a descending list gives the first id the highest value. It bypasses acts_as_list callbacks by design (one `update_all`, no per-row shifting).
|
|
319
330
|
|
|
320
331
|
---
|
|
321
332
|
|
|
@@ -456,13 +467,21 @@ User.soft_delete_all # soft-deletes all matching records; returns the count
|
|
|
456
467
|
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
|
|
457
468
|
User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
|
|
458
469
|
User.restore_all # restores the matching soft-deleted records; returns the count
|
|
470
|
+
|
|
471
|
+
User.deleted_within(1.hour).restore_all # undo a bulk delete — only the last hour's trash
|
|
472
|
+
User.deleted_within(30.days).really_destroy_all # purge recent trash; older rows untouched
|
|
473
|
+
User.only_deleted.really_destroy_all # empty the trash can, nothing else
|
|
459
474
|
```
|
|
460
475
|
|
|
461
476
|
A record that fails to transition raises `ActiveRecord::RecordNotSaved` and rolls the whole
|
|
462
477
|
batch back. With `touch: false` and no overridden hooks, `soft_delete_all` / `restore_all`
|
|
463
|
-
collapse to a single `UPDATE`.
|
|
464
|
-
|
|
465
|
-
|
|
478
|
+
collapse to a single `UPDATE`. Both `restore_all` and `really_destroy_all` peel off **only the
|
|
479
|
+
default scope's own** `deleted_at IS NULL`: a predicate *you* put on the column — `deleted_within`,
|
|
480
|
+
`where(deleted_at: range)`, `only_deleted` — survives, as does any other default scope the model
|
|
481
|
+
declares. (Previously they unscoped the column outright, so `deleted_within(1.hour).restore_all`
|
|
482
|
+
restored the whole trash can and `only_deleted.really_destroy_all` widened to the whole relation.)
|
|
483
|
+
The *scopes* still unscope the column, so chain them first: `soft_deleted.where(...)`, not
|
|
484
|
+
`where(...).soft_deleted`.
|
|
466
485
|
|
|
467
486
|
**Scope-name collisions**
|
|
468
487
|
|
|
@@ -479,6 +498,30 @@ Expirable) without a collision. `prefix: true` uses the configured field name. W
|
|
|
479
498
|
passed, scope names, the default scope, and the emitted SQL are unchanged. See the
|
|
480
499
|
Publishable section above for how `prefix:`/`suffix:` differ across the gem.
|
|
481
500
|
|
|
501
|
+
**Cascading to dependents**
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
class Post < ApplicationRecord
|
|
505
|
+
include ConcernsOnRails::SoftDeletable
|
|
506
|
+
has_many :comments
|
|
507
|
+
has_one :cover
|
|
508
|
+
soft_deletable_by :deleted_at, cascade: %i[comments cover] # Comment and Cover include SoftDeletable too
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
post.soft_delete! # comments + cover soft-deleted in the same transaction, with the post's exact timestamp
|
|
512
|
+
post.restore! # brings back the comments/cover the cascade deleted — NOT a comment someone trashed last week
|
|
513
|
+
post.soft_delete!(at: 1.day.ago) # new at: keyword — backdate, or hand a timestamp down a cascade
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Dependents go through their own `soft_delete!` / `restore!` (hooks and nested cascades run). A dependent
|
|
517
|
+
that fails — whether it raises or just fails validation — aborts the cascade with
|
|
518
|
+
`ActiveRecord::RecordNotSaved` and rolls the parent back with it, so you never end up with a deleted
|
|
519
|
+
parent and a live child. Declare the cascaded associations **above** `soft_deletable_by`; the macro
|
|
520
|
+
resolves them at class load. Restore matches on the parent's timestamp, so independently
|
|
521
|
+
deleted dependents keep their own. `cascade:` accepts `has_many` / `has_one` (no `belongs_to`, HABTM or
|
|
522
|
+
`:through`) whose models include SoftDeletable; with a cascade configured `soft_delete_all` / `restore_all`
|
|
523
|
+
take the per-record path (a bulk `UPDATE` cannot follow associations).
|
|
524
|
+
|
|
482
525
|
**Lifecycle hooks** — override these methods on the model:
|
|
483
526
|
|
|
484
527
|
```ruby
|
|
@@ -854,6 +897,7 @@ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without cre
|
|
|
854
897
|
| `scope:` | `nil` | Column (or array of columns) the counter is scoped to — e.g. one sequence per `account_id`. |
|
|
855
898
|
| `reset:` | `:never` | `:never` / `:year` / `:month` / `:day` — restart numbering each period (needs `created_at`). |
|
|
856
899
|
| `template:` | `nil` | `->(seq, record) { ... }` full custom formatter; overrides `prefix` / `padding` / period. |
|
|
900
|
+
| `assign:` | `:create` | `:create` numbers every record in `before_create`; `:manual` leaves the column NULL until `assign_<field>!` is called — for invoices that get their number when finalized, not when drafted. |
|
|
857
901
|
|
|
858
902
|
**Default format**
|
|
859
903
|
|
|
@@ -870,10 +914,14 @@ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without cre
|
|
|
870
914
|
|-----------------------------------|---------------------------------------------------------------------------------------|
|
|
871
915
|
| `formatted_<field>` | The formatted string — the persisted `into:` value when set, otherwise computed. |
|
|
872
916
|
| `Model.next_<field>(scope_attrs)` | Peek the next integer for a scope without creating a record. |
|
|
917
|
+
| `assign_<field>!` | Number the record now (`assign: :manual`, or any row still blank): next value + `into:` string, `save!`d when persisted, left for your save when new. `true` when assigned, `false` when already numbered. |
|
|
918
|
+
| `<field>_assigned?` | Whether the record has its number. |
|
|
919
|
+
| `Model.pending_<field>` | Scope: rows still awaiting a number (`WHERE <field> IS NULL`). |
|
|
873
920
|
|
|
874
921
|
**Notes**
|
|
875
922
|
- The next value is `MAX(<field>) + 1` within the scope (and period), so numbering is dense and ordered — not random.
|
|
876
923
|
- Caller-supplied values are respected: `Invoice.create!(sequence: 100)` is not overwritten (and its `into:` string is still formatted from `100`).
|
|
924
|
+
- With `assign: :manual`, numbering follows **assignment** order (the first invoice finalized is #1, whenever it was drafted); with `reset:` the period is still taken from the row's `created_at`, exactly as on create.
|
|
877
925
|
- Generation reads `MAX` then inserts, so two concurrent inserts can race. It's **best-effort** — add a **scoped unique index** on `<field>` (and on `into:`) for a real guarantee, the same way you would for any `MAX`-based numbering.
|
|
878
926
|
- `reset:` requires a `created_at` column; the period is taken from each row's creation time.
|
|
879
927
|
- For fixed-width display (`00042`), make the `into:` column a **string** — integer columns drop leading zeros.
|
|
@@ -1565,7 +1613,7 @@ end
|
|
|
1565
1613
|
|
|
1566
1614
|
**Notes**
|
|
1567
1615
|
- 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).
|
|
1568
|
-
- Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present).
|
|
1616
|
+
- Cursors are opaque, table/order-pinned tokens — a malformed, cross-endpoint, or stale-config cursor renders a 400 (`invalid_cursor`; override `render_invalid_cursor` to customize, delegates to Respondable's `render_error` when present). Unsigned by default: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection) and the relation's own scoping still applies — treat a cursor as a page position, never an authorization boundary. **`signed: true`** (or a String key, or a callable for rotating keys) appends a URL-safe HMAC-SHA256 to every cursor and rejects tampered or unsigned tokens with the same 400, so clients can no longer hand-craft positions at all; `true` uses `Rails.application.secret_key_base`. Turning it on invalidates in-flight cursors (clients restart from page one).
|
|
1569
1617
|
- `cursor_paginated` uses `reorder` (replaces any `default_scope` ORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable's `sorted` — pass `order:` per call instead.
|
|
1570
1618
|
- Forward-only by default — `bidirectional: true` (macro or per call) adds prev cursors and `X-Has-Prev`/`X-Prev-Cursor`; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid. `order_presets: { newest: {...}, top: {...} }` (+ `default_preset:`, `order_param:`) lets clients pick a **named** ordering from an allow-list. `predicate: :auto` upgrades the keyset WHERE to a row-value tuple `(a, b, id) > (x, y, z)` on PostgreSQL/MySQL/SQLite when directions are uniform — composite-index friendly — falling back to the portable OR-expansion (`:row`/`:or` force a strategy).
|
|
1571
1619
|
- Use Paginatable when you need page numbers and totals.
|
|
@@ -1681,22 +1729,42 @@ end
|
|
|
1681
1729
|
|
|
1682
1730
|
## 🛟 ErrorHandleable
|
|
1683
1731
|
|
|
1684
|
-
Install `rescue_from` handlers for the
|
|
1732
|
+
Install `rescue_from` handlers for the controller exceptions a JSON API meets in practice and render them as the same JSON envelope used by Respondable.
|
|
1685
1733
|
|
|
1686
1734
|
```ruby
|
|
1687
1735
|
class Api::BaseController < ApplicationController
|
|
1688
1736
|
include ConcernsOnRails::Controllers::Respondable # recommended
|
|
1689
1737
|
include ConcernsOnRails::Controllers::ErrorHandleable
|
|
1738
|
+
|
|
1739
|
+
handle_errors except: :stale_object # optional — let some propagate
|
|
1690
1740
|
end
|
|
1691
1741
|
```
|
|
1692
1742
|
|
|
1693
1743
|
**Handled exceptions**
|
|
1694
1744
|
|
|
1695
|
-
| Exception
|
|
1696
|
-
|
|
1697
|
-
| `ActiveRecord::RecordNotFound`
|
|
1698
|
-
| `ActionController::ParameterMissing`
|
|
1699
|
-
| `ActiveRecord::RecordInvalid`
|
|
1745
|
+
| Key (= `code`) | Exception | Status | `details` |
|
|
1746
|
+
|------------------------------|------------------------------------------------|--------|------------------------------|
|
|
1747
|
+
| `not_found` | `ActiveRecord::RecordNotFound` | 404 | — |
|
|
1748
|
+
| `parameter_missing` | `ActionController::ParameterMissing` | 400 | — |
|
|
1749
|
+
| `record_invalid` | `ActiveRecord::RecordInvalid` | 422 | `errors.full_messages` |
|
|
1750
|
+
| `validation_error` | `ActiveModel::ValidationError` | 422 | `model.errors.full_messages` |
|
|
1751
|
+
| `record_not_saved` | `ActiveRecord::RecordNotSaved` | 422 | record errors, if any |
|
|
1752
|
+
| `record_not_destroyed` | `ActiveRecord::RecordNotDestroyed` | 422 | record errors, if any |
|
|
1753
|
+
| `stale_object` | `ActiveRecord::StaleObjectError` | 409 | — |
|
|
1754
|
+
| `record_not_unique` | `ActiveRecord::RecordNotUnique` | 409 | — |
|
|
1755
|
+
| `foreign_key_violation` | `ActiveRecord::InvalidForeignKey` | 409 | — |
|
|
1756
|
+
| `unpermitted_parameters` | `ActionController::UnpermittedParameters` | 400 | the parameter names |
|
|
1757
|
+
| `invalid_authenticity_token` | `ActionController::InvalidAuthenticityToken` | 422 | — |
|
|
1758
|
+
| `bad_request` | `ActionController::BadRequest` | 400 | — |
|
|
1759
|
+
| `parse_error` | `ActionDispatch::Http::Parameters::ParseError` | 400 | — |
|
|
1760
|
+
| `unknown_format` | `ActionController::UnknownFormat` | 406 | — |
|
|
1761
|
+
|
|
1762
|
+
Statuses follow Rails' own `rescue_responses` wherever Rails has an opinion; the two database-constraint
|
|
1763
|
+
races Rails leaves as 500s (`RecordNotUnique`, `InvalidForeignKey`) get the REST-conventional 409. Messages
|
|
1764
|
+
for database- and parser-level errors are deliberately generic (`"Resource already exists"`,
|
|
1765
|
+
`"Malformed request body"`, …): the raw messages carry SQL fragments, table/column names, model class
|
|
1766
|
+
names or the offending input, none of which belongs in an API response. `details` is present only when
|
|
1767
|
+
there is something to list.
|
|
1700
1768
|
|
|
1701
1769
|
Response shape (matches `Respondable#render_error`):
|
|
1702
1770
|
|
|
@@ -1718,8 +1786,32 @@ class Api::BaseController < ApplicationController
|
|
|
1718
1786
|
end
|
|
1719
1787
|
```
|
|
1720
1788
|
|
|
1789
|
+
**Trimming the map**
|
|
1790
|
+
|
|
1791
|
+
```ruby
|
|
1792
|
+
handle_errors except: :stale_object # let optimistic-lock conflicts reach the error tracker
|
|
1793
|
+
handle_errors except: %i[record_not_unique foreign_key_violation] # calls accumulate
|
|
1794
|
+
handle_errors only: %i[not_found parameter_missing record_invalid] # just the original trio
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
`handle_errors` removes only the concern's own registrations (matched on exception *and* handler), so a
|
|
1798
|
+
`rescue_from` you declared yourself for the same exception is untouched, and it never re-adds — your later
|
|
1799
|
+
declarations keep precedence. Unknown keys raise `ArgumentError` listing the valid ones;
|
|
1800
|
+
`error_handleable_keys` returns the keys still active on a controller.
|
|
1801
|
+
|
|
1802
|
+
**Reporting** — every handled error instruments `handled_error.concerns_on_rails` (`controller`, `action`, `code`, `status`, `message`, `exception`, `exception_class`) via the public `on_handled_error(key, error, status:, message:)` override point, so the 409s reach your error tracker while the 404s stay quiet:
|
|
1803
|
+
|
|
1804
|
+
```ruby
|
|
1805
|
+
def on_handled_error(key, error, **)
|
|
1806
|
+
Sentry.capture_exception(error) if %i[record_not_unique foreign_key_violation stale_object].include?(key)
|
|
1807
|
+
super # keep the event
|
|
1808
|
+
end
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1721
1811
|
**Notes**
|
|
1722
1812
|
- When `Respondable` is also included, the handlers delegate to `render_error` so the envelope shape stays in one place. Otherwise they render the same envelope inline.
|
|
1813
|
+
- Exceptions are registered by name (string), so a class your Rails version lacks is simply never matched.
|
|
1814
|
+
- `ActionController::UnpermittedParameters` is only raised with `config.action_controller.action_on_unpermitted_parameters = :raise`.
|
|
1723
1815
|
- `RecordInvalid.details` are populated from `error.record.errors.full_messages`.
|
|
1724
1816
|
|
|
1725
1817
|
---
|
|
@@ -2123,7 +2215,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
2123
2215
|
| Need | Use instead |
|
|
2124
2216
|
|------|-------------|
|
|
2125
2217
|
| Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
|
|
2126
|
-
|
|
|
2218
|
+
| Sentinel-aware unique indexes on soft-deleted rows (`deleted_at` in the index) | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
|
|
2127
2219
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
2128
2220
|
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
2129
2221
|
| Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
|
|
@@ -2157,9 +2249,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2157
2249
|
|
|
2158
2250
|
```sh
|
|
2159
2251
|
bundle install # install dev dependencies
|
|
2160
|
-
bundle exec rspec # run the test suite (1,
|
|
2252
|
+
bundle exec rspec # run the test suite (1,396 examples)
|
|
2161
2253
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2162
|
-
gem install ./concerns_on_rails-1.28.
|
|
2254
|
+
gem install ./concerns_on_rails-1.28.3.gem # install locally
|
|
2163
2255
|
|
|
2164
2256
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2165
2257
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -3,6 +3,8 @@ require "concerns_on_rails/support/error_envelope"
|
|
|
3
3
|
require "concerns_on_rails/support/scalar_param"
|
|
4
4
|
require "json"
|
|
5
5
|
require "concerns_on_rails/support/link_header"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "active_support/security_utils"
|
|
6
8
|
require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
|
|
7
9
|
|
|
8
10
|
module ConcernsOnRails
|
|
@@ -143,6 +145,9 @@ module ConcernsOnRails
|
|
|
143
145
|
class_attribute :cursor_paginatable_bidirectional, default: false
|
|
144
146
|
class_attribute :cursor_paginatable_predicate, default: :auto
|
|
145
147
|
class_attribute :cursor_paginatable_link_header, default: true
|
|
148
|
+
# false (unsigned), true (Rails.application.secret_key_base), a String
|
|
149
|
+
# key, or a callable returning the key — see cursor_paginate_by.
|
|
150
|
+
class_attribute :cursor_paginatable_signed, default: false
|
|
146
151
|
|
|
147
152
|
# Real controllers (anything with ActiveSupport::Rescuable) get the 400
|
|
148
153
|
# handlers automatically; bare objects let the errors propagate.
|
|
@@ -163,7 +168,7 @@ module ConcernsOnRails
|
|
|
163
168
|
# max_per_page: 0 (or negative) disables the per_page cap.
|
|
164
169
|
def cursor_paginate_by(order: nil, order_presets: nil, default_preset: nil, order_param: :order,
|
|
165
170
|
per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE,
|
|
166
|
-
bidirectional: false, predicate: :auto, link_header: true)
|
|
171
|
+
bidirectional: false, predicate: :auto, link_header: true, signed: false)
|
|
167
172
|
self.cursor_paginatable_order = order && CursorPaginatable.normalize_order!(order)
|
|
168
173
|
self.cursor_paginatable_order_presets = order_presets && CursorPaginatable.normalize_presets!(order_presets)
|
|
169
174
|
self.cursor_paginatable_default_preset =
|
|
@@ -174,9 +179,19 @@ module ConcernsOnRails
|
|
|
174
179
|
self.cursor_paginatable_bidirectional = bidirectional ? true : false
|
|
175
180
|
self.cursor_paginatable_predicate = CursorPaginatable.validate_predicate!(predicate)
|
|
176
181
|
self.cursor_paginatable_link_header = link_header ? true : false
|
|
182
|
+
self.cursor_paginatable_signed = CursorPaginatable.validate_signed!(signed)
|
|
177
183
|
end
|
|
178
184
|
end
|
|
179
185
|
|
|
186
|
+
# `signed:` — false, true (the app's secret_key_base), a non-blank String,
|
|
187
|
+
# or a callable (resolved per request, so keys can rotate).
|
|
188
|
+
def self.validate_signed!(signed)
|
|
189
|
+
return signed if [true, false].include?(signed) || signed.respond_to?(:call)
|
|
190
|
+
return signed if signed.is_a?(String) && !signed.strip.empty?
|
|
191
|
+
|
|
192
|
+
raise ArgumentError, "#{name}: signed: must be true, false, a String or a callable"
|
|
193
|
+
end
|
|
194
|
+
|
|
180
195
|
# Run the keyset query (limit + 1 to detect has_more), set the standard
|
|
181
196
|
# response headers, and return the page as a loaded Array (laziness is
|
|
182
197
|
# impossible here: has_more detection materializes limit + 1 rows).
|
|
@@ -339,7 +354,55 @@ module ConcernsOnRails
|
|
|
339
354
|
"d" => direction,
|
|
340
355
|
"v" => pairs.map { |col, _dir| serialize_cursor_value(cursor_boundary_value!(record, col)) }
|
|
341
356
|
}
|
|
342
|
-
cursor_base64_encode(JSON.generate(payload))
|
|
357
|
+
token = cursor_base64_encode(JSON.generate(payload))
|
|
358
|
+
cursor_signing_key ? "#{token}.#{cursor_signature(token)}" : token
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Signed cursors are `<url-safe base64 payload>.<hex HMAC-SHA256>` — the
|
|
362
|
+
# dot never appears in the payload alphabet, so the split is unambiguous
|
|
363
|
+
# and the whole token stays URL-safe. Verification is constant-time and
|
|
364
|
+
# happens BEFORE the payload is parsed; an unsigned or mis-signed token
|
|
365
|
+
# on a signed endpoint is rejected (fail closed). Turning signing on
|
|
366
|
+
# therefore invalidates in-flight cursors: clients restart from page one.
|
|
367
|
+
def cursor_signature(payload_token)
|
|
368
|
+
OpenSSL::HMAC.hexdigest("SHA256", cursor_signing_key, payload_token)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# nil when unsigned; the key otherwise. `true` reads
|
|
372
|
+
# Rails.application.secret_key_base (raising a setup hint outside Rails).
|
|
373
|
+
def cursor_signing_key
|
|
374
|
+
configured = self.class.cursor_paginatable_signed
|
|
375
|
+
return nil if configured == false || configured.nil?
|
|
376
|
+
return cursor_rails_secret_key_base if configured == true
|
|
377
|
+
|
|
378
|
+
key = configured.respond_to?(:call) ? configured.call : configured
|
|
379
|
+
raise ArgumentError, "#{self.class.name}: signed: resolved to a blank key" if key.to_s.strip.empty?
|
|
380
|
+
|
|
381
|
+
key.to_s
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def cursor_rails_secret_key_base
|
|
385
|
+
app = defined?(Rails) && Rails.respond_to?(:application) ? Rails.application : nil
|
|
386
|
+
key = app.respond_to?(:secret_key_base) ? app.secret_key_base : nil
|
|
387
|
+
return key.to_s unless key.to_s.strip.empty?
|
|
388
|
+
|
|
389
|
+
raise ArgumentError,
|
|
390
|
+
"ConcernsOnRails::Controllers::CursorPaginatable: signed: true needs Rails.application.secret_key_base; " \
|
|
391
|
+
"outside a Rails app pass signed: -> { ... } (a callable returning the key) or a String."
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Splits and verifies a signed token, returning the payload half; raises
|
|
395
|
+
# InvalidCursor when the endpoint is signed and the token is not (or its
|
|
396
|
+
# signature does not match).
|
|
397
|
+
def verify_cursor_signature!(raw)
|
|
398
|
+
return raw unless cursor_signing_key
|
|
399
|
+
|
|
400
|
+
payload, signature, extra = raw.split(".", 3)
|
|
401
|
+
valid = extra.nil? && !payload.to_s.empty? && signature.to_s.match?(/\A[0-9a-f]{64}\z/) &&
|
|
402
|
+
ActiveSupport::SecurityUtils.secure_compare(signature, cursor_signature(payload))
|
|
403
|
+
raise InvalidCursor, "Invalid pagination cursor." unless valid
|
|
404
|
+
|
|
405
|
+
payload
|
|
343
406
|
end
|
|
344
407
|
|
|
345
408
|
# A NULL boundary value would emit `col > NULL` — never TRUE in SQL
|
|
@@ -375,7 +438,7 @@ module ConcernsOnRails
|
|
|
375
438
|
def decode_cursor(raw, pairs, model, bidirectional:)
|
|
376
439
|
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
377
440
|
|
|
378
|
-
payload = parse_cursor_payload(raw.to_s)
|
|
441
|
+
payload = parse_cursor_payload(verify_cursor_signature!(raw.to_s))
|
|
379
442
|
raise InvalidCursor, "Invalid pagination cursor." unless payload
|
|
380
443
|
|
|
381
444
|
verify_cursor_scope!(payload, pairs, model)
|