concerns_on_rails 1.28.1 → 1.28.2
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 +56 -0
- data/README.md +72 -12
- 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/sequenceable.rb +31 -6
- data/lib/concerns_on_rails/models/sluggable.rb +64 -1
- data/lib/concerns_on_rails/models/sortable.rb +65 -0
- 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: b0a05169c98ce90531027509dbbdd4f00722678e2cfe246a5692484d9a12a111
|
|
4
|
+
data.tar.gz: 10d2cebdced9beed7b677b18a0b8c85dc82d1125672ada2f5c5f12b995fa1933
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 756646b2232fa428250bf502fbb7ce87a44bf39830d9ae4810e50482315394fa1aa0b0e1ed47fb9a4b87d677d11507adfb7e14206f0b27119235060c74f1513b
|
|
7
|
+
data.tar.gz: '08b14db3a23711b0531447c773b2d831ec37fcf8c2b84fa59b4f203c7375923297e3a9dd93597b6e0091f18e677752be0dca67b913a4a3b1dd61fb042b417fad'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.2 (2026-09-11)
|
|
4
|
+
|
|
5
|
+
Six merged enhancement PRs from the September loop (#42, #84, #59 via #90, #80,
|
|
6
|
+
#81, #82), all additive and column-free: ErrorHandleable rescues 14 exceptions
|
|
7
|
+
instead of 3 and instruments every handled error, CursorPaginatable cursors can
|
|
8
|
+
be HMAC-signed, Sortable applies a drag-and-drop order in one UPDATE, Sluggable
|
|
9
|
+
gains slug candidates / word-boundary truncation / forced regeneration, and
|
|
10
|
+
Sequenceable can defer numbering until an invoice is finalized. No new
|
|
11
|
+
migrations or runtime dependencies. 1360 examples, 0 failures.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Controllers::ErrorHandleable**: the exception map grows from 3 to 14 —
|
|
15
|
+
`ActiveModel::ValidationError`, `RecordNotSaved`, `RecordNotDestroyed` (422),
|
|
16
|
+
`StaleObjectError`, `RecordNotUnique`, `InvalidForeignKey` (409),
|
|
17
|
+
`UnpermittedParameters`, `BadRequest`, `ParseError` (400),
|
|
18
|
+
`InvalidAuthenticityToken` (422) and `UnknownFormat` (406). Statuses follow
|
|
19
|
+
Rails' `rescue_responses`; database/parser-level errors render generic
|
|
20
|
+
messages so SQL, schema names and request input never leak. New
|
|
21
|
+
`handle_errors only:/except:` macro trims the map without touching a host
|
|
22
|
+
`rescue_from`; `error_handleable_keys` lists the active keys. (#42)
|
|
23
|
+
- **Controllers::ErrorHandleable**: every handled error instruments
|
|
24
|
+
`handled_error.concerns_on_rails` (controller, action, code, status, message,
|
|
25
|
+
exception) via the public `on_handled_error(key, error, status:, message:)`
|
|
26
|
+
override point — report some codes to the error tracker, let the rest stay
|
|
27
|
+
quiet. (#84)
|
|
28
|
+
- **Controllers::CursorPaginatable**: `cursor_paginate_by … signed: true` (or a
|
|
29
|
+
String key / callable) appends a URL-safe HMAC-SHA256 to every cursor and
|
|
30
|
+
rejects tampered, mis-signed or unsigned tokens with the usual 400, so
|
|
31
|
+
clients can no longer hand-craft page positions. Unsigned stays the default.
|
|
32
|
+
(#59, merged via #90)
|
|
33
|
+
- **Sortable (model)**: `Model.reposition!(ids, missing: :append | :raise)`
|
|
34
|
+
applies a drag-and-drop id order as positions in one `UPDATE … CASE` inside
|
|
35
|
+
the current relation (unlisted rows appended or raised, foreign/duplicate ids
|
|
36
|
+
rejected, String ids cast, `:desc` lists inverted). Returns the count. (#80)
|
|
37
|
+
- **Sluggable**: `candidates:` (friendly_id slug candidates tried in order
|
|
38
|
+
before the uuid fallback; regeneration still keyed to the primary field),
|
|
39
|
+
`max_length:` (word-boundary truncation, suffix appended after) and
|
|
40
|
+
`regenerate_slug!` (rebuild from the current source even over a
|
|
41
|
+
hand-assigned slug). (#81)
|
|
42
|
+
- **Sequenceable**: `assign: :manual` defers numbering until `assign_<field>!`
|
|
43
|
+
(idempotent — `true` when assigned, `false` when already numbered; `save!`s
|
|
44
|
+
persisted records, leaves new ones for the caller's save), plus
|
|
45
|
+
`<field>_assigned?` and a `pending_<field>` scope. Numbering then follows
|
|
46
|
+
finalization order; `reset:` periods still come from `created_at`. (#82)
|
|
47
|
+
|
|
48
|
+
### Notes
|
|
49
|
+
Apps that include ErrorHandleable now get 11 more exceptions rescued into JSON
|
|
50
|
+
4xx responses. One that relied on, say, `StaleObjectError` propagating to its
|
|
51
|
+
error tracker should add `handle_errors except: :stale_object` (or `only:` the
|
|
52
|
+
original trio) — or override `on_handled_error` to report the codes it cares
|
|
53
|
+
about. Exceptions are registered by name, so a class a given Rails version
|
|
54
|
+
lacks is simply never matched. Turning on `signed:` invalidates in-flight
|
|
55
|
+
unsigned cursors (there is no mixed mode by design), so clients restart from
|
|
56
|
+
the first page. `reposition!` writes positions with `update_all` and therefore
|
|
57
|
+
bypasses acts_as_list's per-row callbacks by design.
|
|
58
|
+
|
|
3
59
|
## 1.28.1 (2026-09-07)
|
|
4
60
|
|
|
5
61
|
One additive Paginatable option (#89): `pagination_meta` can now publish the
|
data/README.md
CHANGED
|
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
|
|
|
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
149
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early — with a ready-to-paste `rails generate migration` hint when it doesn't
|
|
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,360 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
|
|
|
@@ -854,6 +865,7 @@ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without cre
|
|
|
854
865
|
| `scope:` | `nil` | Column (or array of columns) the counter is scoped to — e.g. one sequence per `account_id`. |
|
|
855
866
|
| `reset:` | `:never` | `:never` / `:year` / `:month` / `:day` — restart numbering each period (needs `created_at`). |
|
|
856
867
|
| `template:` | `nil` | `->(seq, record) { ... }` full custom formatter; overrides `prefix` / `padding` / period. |
|
|
868
|
+
| `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
869
|
|
|
858
870
|
**Default format**
|
|
859
871
|
|
|
@@ -870,10 +882,14 @@ Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without cre
|
|
|
870
882
|
|-----------------------------------|---------------------------------------------------------------------------------------|
|
|
871
883
|
| `formatted_<field>` | The formatted string — the persisted `into:` value when set, otherwise computed. |
|
|
872
884
|
| `Model.next_<field>(scope_attrs)` | Peek the next integer for a scope without creating a record. |
|
|
885
|
+
| `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. |
|
|
886
|
+
| `<field>_assigned?` | Whether the record has its number. |
|
|
887
|
+
| `Model.pending_<field>` | Scope: rows still awaiting a number (`WHERE <field> IS NULL`). |
|
|
873
888
|
|
|
874
889
|
**Notes**
|
|
875
890
|
- The next value is `MAX(<field>) + 1` within the scope (and period), so numbering is dense and ordered — not random.
|
|
876
891
|
- Caller-supplied values are respected: `Invoice.create!(sequence: 100)` is not overwritten (and its `into:` string is still formatted from `100`).
|
|
892
|
+
- 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
893
|
- 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
894
|
- `reset:` requires a `created_at` column; the period is taken from each row's creation time.
|
|
879
895
|
- For fixed-width display (`00042`), make the `into:` column a **string** — integer columns drop leading zeros.
|
|
@@ -1565,7 +1581,7 @@ end
|
|
|
1565
1581
|
|
|
1566
1582
|
**Notes**
|
|
1567
1583
|
- 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).
|
|
1584
|
+
- 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
1585
|
- `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
1586
|
- 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
1587
|
- Use Paginatable when you need page numbers and totals.
|
|
@@ -1681,22 +1697,42 @@ end
|
|
|
1681
1697
|
|
|
1682
1698
|
## 🛟 ErrorHandleable
|
|
1683
1699
|
|
|
1684
|
-
Install `rescue_from` handlers for the
|
|
1700
|
+
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
1701
|
|
|
1686
1702
|
```ruby
|
|
1687
1703
|
class Api::BaseController < ApplicationController
|
|
1688
1704
|
include ConcernsOnRails::Controllers::Respondable # recommended
|
|
1689
1705
|
include ConcernsOnRails::Controllers::ErrorHandleable
|
|
1706
|
+
|
|
1707
|
+
handle_errors except: :stale_object # optional — let some propagate
|
|
1690
1708
|
end
|
|
1691
1709
|
```
|
|
1692
1710
|
|
|
1693
1711
|
**Handled exceptions**
|
|
1694
1712
|
|
|
1695
|
-
| Exception
|
|
1696
|
-
|
|
1697
|
-
| `ActiveRecord::RecordNotFound`
|
|
1698
|
-
| `ActionController::ParameterMissing`
|
|
1699
|
-
| `ActiveRecord::RecordInvalid`
|
|
1713
|
+
| Key (= `code`) | Exception | Status | `details` |
|
|
1714
|
+
|------------------------------|------------------------------------------------|--------|------------------------------|
|
|
1715
|
+
| `not_found` | `ActiveRecord::RecordNotFound` | 404 | — |
|
|
1716
|
+
| `parameter_missing` | `ActionController::ParameterMissing` | 400 | — |
|
|
1717
|
+
| `record_invalid` | `ActiveRecord::RecordInvalid` | 422 | `errors.full_messages` |
|
|
1718
|
+
| `validation_error` | `ActiveModel::ValidationError` | 422 | `model.errors.full_messages` |
|
|
1719
|
+
| `record_not_saved` | `ActiveRecord::RecordNotSaved` | 422 | record errors, if any |
|
|
1720
|
+
| `record_not_destroyed` | `ActiveRecord::RecordNotDestroyed` | 422 | record errors, if any |
|
|
1721
|
+
| `stale_object` | `ActiveRecord::StaleObjectError` | 409 | — |
|
|
1722
|
+
| `record_not_unique` | `ActiveRecord::RecordNotUnique` | 409 | — |
|
|
1723
|
+
| `foreign_key_violation` | `ActiveRecord::InvalidForeignKey` | 409 | — |
|
|
1724
|
+
| `unpermitted_parameters` | `ActionController::UnpermittedParameters` | 400 | the parameter names |
|
|
1725
|
+
| `invalid_authenticity_token` | `ActionController::InvalidAuthenticityToken` | 422 | — |
|
|
1726
|
+
| `bad_request` | `ActionController::BadRequest` | 400 | — |
|
|
1727
|
+
| `parse_error` | `ActionDispatch::Http::Parameters::ParseError` | 400 | — |
|
|
1728
|
+
| `unknown_format` | `ActionController::UnknownFormat` | 406 | — |
|
|
1729
|
+
|
|
1730
|
+
Statuses follow Rails' own `rescue_responses` wherever Rails has an opinion; the two database-constraint
|
|
1731
|
+
races Rails leaves as 500s (`RecordNotUnique`, `InvalidForeignKey`) get the REST-conventional 409. Messages
|
|
1732
|
+
for database- and parser-level errors are deliberately generic (`"Resource already exists"`,
|
|
1733
|
+
`"Malformed request body"`, …): the raw messages carry SQL fragments, table/column names, model class
|
|
1734
|
+
names or the offending input, none of which belongs in an API response. `details` is present only when
|
|
1735
|
+
there is something to list.
|
|
1700
1736
|
|
|
1701
1737
|
Response shape (matches `Respondable#render_error`):
|
|
1702
1738
|
|
|
@@ -1718,8 +1754,32 @@ class Api::BaseController < ApplicationController
|
|
|
1718
1754
|
end
|
|
1719
1755
|
```
|
|
1720
1756
|
|
|
1757
|
+
**Trimming the map**
|
|
1758
|
+
|
|
1759
|
+
```ruby
|
|
1760
|
+
handle_errors except: :stale_object # let optimistic-lock conflicts reach the error tracker
|
|
1761
|
+
handle_errors except: %i[record_not_unique foreign_key_violation] # calls accumulate
|
|
1762
|
+
handle_errors only: %i[not_found parameter_missing record_invalid] # just the original trio
|
|
1763
|
+
```
|
|
1764
|
+
|
|
1765
|
+
`handle_errors` removes only the concern's own registrations (matched on exception *and* handler), so a
|
|
1766
|
+
`rescue_from` you declared yourself for the same exception is untouched, and it never re-adds — your later
|
|
1767
|
+
declarations keep precedence. Unknown keys raise `ArgumentError` listing the valid ones;
|
|
1768
|
+
`error_handleable_keys` returns the keys still active on a controller.
|
|
1769
|
+
|
|
1770
|
+
**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:
|
|
1771
|
+
|
|
1772
|
+
```ruby
|
|
1773
|
+
def on_handled_error(key, error, **)
|
|
1774
|
+
Sentry.capture_exception(error) if %i[record_not_unique foreign_key_violation stale_object].include?(key)
|
|
1775
|
+
super # keep the event
|
|
1776
|
+
end
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1721
1779
|
**Notes**
|
|
1722
1780
|
- 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.
|
|
1781
|
+
- Exceptions are registered by name (string), so a class your Rails version lacks is simply never matched.
|
|
1782
|
+
- `ActionController::UnpermittedParameters` is only raised with `config.action_controller.action_on_unpermitted_parameters = :raise`.
|
|
1723
1783
|
- `RecordInvalid.details` are populated from `error.record.errors.full_messages`.
|
|
1724
1784
|
|
|
1725
1785
|
---
|
|
@@ -2157,9 +2217,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2157
2217
|
|
|
2158
2218
|
```sh
|
|
2159
2219
|
bundle install # install dev dependencies
|
|
2160
|
-
bundle exec rspec # run the test suite (1,
|
|
2220
|
+
bundle exec rspec # run the test suite (1,360 examples)
|
|
2161
2221
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2162
|
-
gem install ./concerns_on_rails-1.28.
|
|
2222
|
+
gem install ./concerns_on_rails-1.28.2.gem # install locally
|
|
2163
2223
|
|
|
2164
2224
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2165
2225
|
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)
|
|
@@ -3,18 +3,38 @@ require "concerns_on_rails/support/error_envelope"
|
|
|
3
3
|
|
|
4
4
|
module ConcernsOnRails
|
|
5
5
|
module Controllers
|
|
6
|
-
# Installs `rescue_from` handlers for the
|
|
7
|
-
#
|
|
6
|
+
# Installs `rescue_from` handlers for the controller exceptions a JSON API
|
|
7
|
+
# meets in practice and renders each as the error envelope Respondable uses.
|
|
8
8
|
#
|
|
9
9
|
# class Api::BaseController < ApplicationController
|
|
10
10
|
# include ConcernsOnRails::Controllers::Respondable # optional, but recommended
|
|
11
11
|
# include ConcernsOnRails::Controllers::ErrorHandleable
|
|
12
|
+
#
|
|
13
|
+
# handle_errors except: :stale_object # optional: let some propagate
|
|
12
14
|
# end
|
|
13
15
|
#
|
|
14
|
-
# Handled:
|
|
15
|
-
# * ActiveRecord::RecordNotFound
|
|
16
|
-
# * ActionController::ParameterMissing
|
|
17
|
-
# * ActiveRecord::RecordInvalid
|
|
16
|
+
# Handled (key → exception → status):
|
|
17
|
+
# * :not_found ActiveRecord::RecordNotFound 404
|
|
18
|
+
# * :parameter_missing ActionController::ParameterMissing 400
|
|
19
|
+
# * :record_invalid ActiveRecord::RecordInvalid 422 (+ field errors)
|
|
20
|
+
# * :validation_error ActiveModel::ValidationError 422 (+ field errors)
|
|
21
|
+
# * :record_not_saved ActiveRecord::RecordNotSaved 422 (+ field errors, if any)
|
|
22
|
+
# * :record_not_destroyed ActiveRecord::RecordNotDestroyed 422 (+ field errors, if any)
|
|
23
|
+
# * :stale_object ActiveRecord::StaleObjectError 409
|
|
24
|
+
# * :record_not_unique ActiveRecord::RecordNotUnique 409
|
|
25
|
+
# * :foreign_key_violation ActiveRecord::InvalidForeignKey 409
|
|
26
|
+
# * :unpermitted_parameters ActionController::UnpermittedParameters 400 (+ the param names)
|
|
27
|
+
# * :invalid_authenticity_token ActionController::InvalidAuthenticityToken 422
|
|
28
|
+
# * :bad_request ActionController::BadRequest 400
|
|
29
|
+
# * :parse_error ActionDispatch::Http::Parameters::ParseError 400
|
|
30
|
+
# * :unknown_format ActionController::UnknownFormat 406
|
|
31
|
+
#
|
|
32
|
+
# Statuses follow Rails' own `rescue_responses` wherever Rails has an
|
|
33
|
+
# opinion; the two database-constraint races Rails leaves as 500s
|
|
34
|
+
# (RecordNotUnique, InvalidForeignKey) get the REST-conventional 409.
|
|
35
|
+
# Messages for database- and parser-level errors are deliberately generic:
|
|
36
|
+
# the raw messages carry SQL fragments, table/column names, model class
|
|
37
|
+
# names or the offending input, none of which belongs in an API response.
|
|
18
38
|
#
|
|
19
39
|
# If Respondable is also included on the controller, the handlers delegate
|
|
20
40
|
# to `render_error` so the envelope shape stays in one place. Otherwise the
|
|
@@ -22,53 +42,234 @@ module ConcernsOnRails
|
|
|
22
42
|
#
|
|
23
43
|
# Each handler is a public instance method, so subclasses can override the
|
|
24
44
|
# message wording or response shape without re-declaring the `rescue_from`.
|
|
45
|
+
#
|
|
46
|
+
# Every handled error instruments `handled_error.concerns_on_rails`
|
|
47
|
+
# (controller, action, code, status, message, exception) through the public
|
|
48
|
+
# `on_handled_error(key, error, status:, message:)` override point — the
|
|
49
|
+
# place to report the 409s to Sentry while letting the 404s pass quietly.
|
|
25
50
|
module ErrorHandleable
|
|
26
51
|
extend ActiveSupport::Concern
|
|
27
52
|
|
|
53
|
+
LABEL = "ConcernsOnRails::Controllers::ErrorHandleable".freeze
|
|
54
|
+
|
|
55
|
+
# The envelope `code` is the key; the status is what the handler renders.
|
|
56
|
+
# Exception names are strings so registration never forces a constant to
|
|
57
|
+
# load (and a name absent from the host's Rails version is simply never
|
|
58
|
+
# matched — `rescue_from` safe_constantizes at rescue time).
|
|
59
|
+
HANDLERS = {
|
|
60
|
+
not_found: { exception: "ActiveRecord::RecordNotFound",
|
|
61
|
+
handler: :handle_record_not_found, status: :not_found },
|
|
62
|
+
parameter_missing: { exception: "ActionController::ParameterMissing",
|
|
63
|
+
handler: :handle_parameter_missing, status: :bad_request },
|
|
64
|
+
record_invalid: { exception: "ActiveRecord::RecordInvalid",
|
|
65
|
+
handler: :handle_record_invalid, status: :unprocessable_entity },
|
|
66
|
+
validation_error: { exception: "ActiveModel::ValidationError",
|
|
67
|
+
handler: :handle_validation_error, status: :unprocessable_entity },
|
|
68
|
+
record_not_saved: { exception: "ActiveRecord::RecordNotSaved",
|
|
69
|
+
handler: :handle_record_not_saved, status: :unprocessable_entity },
|
|
70
|
+
record_not_destroyed: { exception: "ActiveRecord::RecordNotDestroyed",
|
|
71
|
+
handler: :handle_record_not_destroyed, status: :unprocessable_entity },
|
|
72
|
+
stale_object: { exception: "ActiveRecord::StaleObjectError",
|
|
73
|
+
handler: :handle_stale_object, status: :conflict },
|
|
74
|
+
record_not_unique: { exception: "ActiveRecord::RecordNotUnique",
|
|
75
|
+
handler: :handle_record_not_unique, status: :conflict },
|
|
76
|
+
foreign_key_violation: { exception: "ActiveRecord::InvalidForeignKey",
|
|
77
|
+
handler: :handle_invalid_foreign_key, status: :conflict },
|
|
78
|
+
unpermitted_parameters: { exception: "ActionController::UnpermittedParameters",
|
|
79
|
+
handler: :handle_unpermitted_parameters, status: :bad_request },
|
|
80
|
+
invalid_authenticity_token: { exception: "ActionController::InvalidAuthenticityToken",
|
|
81
|
+
handler: :handle_invalid_authenticity_token, status: :unprocessable_entity },
|
|
82
|
+
bad_request: { exception: "ActionController::BadRequest",
|
|
83
|
+
handler: :handle_bad_request, status: :bad_request },
|
|
84
|
+
parse_error: { exception: "ActionDispatch::Http::Parameters::ParseError",
|
|
85
|
+
handler: :handle_parse_error, status: :bad_request },
|
|
86
|
+
unknown_format: { exception: "ActionController::UnknownFormat",
|
|
87
|
+
handler: :handle_unknown_format, status: :not_acceptable }
|
|
88
|
+
}.freeze
|
|
89
|
+
|
|
28
90
|
included do
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
91
|
+
# The keys still handled on this controller (trimmed by `handle_errors`).
|
|
92
|
+
class_attribute :error_handleable_keys, instance_accessor: false, default: HANDLERS.keys
|
|
93
|
+
|
|
94
|
+
HANDLERS.each_value do |spec|
|
|
95
|
+
rescue_from spec[:exception], with: spec[:handler]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class_methods do
|
|
100
|
+
# Trim the default map: `only:` keeps just those keys, `except:` drops
|
|
101
|
+
# them. Accepts a symbol or a list; calls accumulate. Removes only the
|
|
102
|
+
# concern's OWN registrations (matched on exception name AND handler
|
|
103
|
+
# method), so a `rescue_from` the host declared for the same exception
|
|
104
|
+
# is untouched — and nothing is ever re-added, so the host's later
|
|
105
|
+
# declarations keep their precedence. Returns the keys still active.
|
|
106
|
+
#
|
|
107
|
+
# handle_errors except: :stale_object # let lock conflicts reach the error tracker
|
|
108
|
+
# handle_errors only: %i[not_found parameter_missing record_invalid]
|
|
109
|
+
def handle_errors(only: nil, except: nil)
|
|
110
|
+
keep = error_handleable_selection(only: only, except: except)
|
|
111
|
+
dropped = HANDLERS.filter_map { |key, spec| [spec[:exception], spec[:handler]] unless keep.include?(key) }
|
|
112
|
+
self.rescue_handlers = rescue_handlers.reject { |entry| dropped.include?(entry) }
|
|
113
|
+
self.error_handleable_keys = error_handleable_keys & keep
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Validates only:/except: and returns the HANDLERS keys to keep.
|
|
117
|
+
def error_handleable_selection(only:, except:)
|
|
118
|
+
raise ArgumentError, "#{LABEL}: pass only: or except:, not both" if only && except
|
|
119
|
+
|
|
120
|
+
keys = error_handleable_known_keys(Array(only || except))
|
|
121
|
+
only ? keys : HANDLERS.keys - keys
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def error_handleable_known_keys(list)
|
|
125
|
+
keys = list.map(&:to_sym)
|
|
126
|
+
unknown = keys - HANDLERS.keys
|
|
127
|
+
return keys if unknown.empty?
|
|
128
|
+
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"#{LABEL}: unknown handler key(s) #{unknown.map(&:inspect).join(', ')} — " \
|
|
131
|
+
"valid keys: #{HANDLERS.keys.map(&:inspect).join(', ')}"
|
|
132
|
+
end
|
|
133
|
+
private :error_handleable_selection, :error_handleable_known_keys
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Called before each handled error is rendered. Default: instrument
|
|
137
|
+
# `handled_error.concerns_on_rails` with the controller path, action, the
|
|
138
|
+
# envelope code (the HANDLERS key), status, rendered message, and the
|
|
139
|
+
# exception (nil when a handler is called directly rather than rescued).
|
|
140
|
+
# Override to report selectively — call super to keep the event, and
|
|
141
|
+
# rescue inside your override if the reporter itself can fail.
|
|
142
|
+
def on_handled_error(key, error, status:, message:)
|
|
143
|
+
ActiveSupport::Notifications.instrument(
|
|
144
|
+
"handled_error.concerns_on_rails",
|
|
145
|
+
controller: error_handleable_controller_name, action: error_handleable_action_name,
|
|
146
|
+
code: key, status: status, message: message, exception: error, exception_class: error&.class&.name
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Remember the exception being rescued so the render funnel can hand it to
|
|
151
|
+
# `on_handled_error` — the handlers themselves only pass a message along.
|
|
152
|
+
# Rescuable's `rescue_with_handler(exception, object:, visited_exceptions:)`
|
|
153
|
+
# is what ActionController's rescue path calls.
|
|
154
|
+
def rescue_with_handler(exception, **)
|
|
155
|
+
@error_handleable_exception = exception
|
|
156
|
+
super
|
|
157
|
+
ensure
|
|
158
|
+
@error_handleable_exception = nil
|
|
32
159
|
end
|
|
33
160
|
|
|
34
161
|
def handle_record_not_found(_error)
|
|
35
162
|
# Use a generic message: the raw RecordNotFound message leaks the model
|
|
36
163
|
# class name and the queried attribute/value to API clients. Subclasses
|
|
37
164
|
# can override this method to surface detail in non-production envs.
|
|
38
|
-
|
|
39
|
-
message: "Resource not found",
|
|
40
|
-
code: "not_found",
|
|
41
|
-
status: :not_found
|
|
42
|
-
)
|
|
165
|
+
render_handled_error(:not_found, message: "Resource not found")
|
|
43
166
|
end
|
|
44
167
|
|
|
45
168
|
def handle_parameter_missing(error)
|
|
46
|
-
|
|
47
|
-
message: "Parameter missing: #{error.param}",
|
|
48
|
-
code: "parameter_missing",
|
|
49
|
-
status: :bad_request
|
|
50
|
-
)
|
|
169
|
+
render_handled_error(:parameter_missing, message: "Parameter missing: #{error.param}")
|
|
51
170
|
end
|
|
52
171
|
|
|
53
172
|
def handle_record_invalid(error)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
173
|
+
render_handled_error(:record_invalid, message: error.message, errors: record_error_details(error))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# `validate!` on a plain ActiveModel::Model (form objects, service inputs).
|
|
177
|
+
def handle_validation_error(error)
|
|
178
|
+
model = error.respond_to?(:model) ? error.model : nil
|
|
179
|
+
render_handled_error(:validation_error, message: error.message, errors: error_messages_of(model))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# `save!` refused by a callback abort (`throw :abort`) — the record may or
|
|
183
|
+
# may not carry errors, so details are attached only when it does.
|
|
184
|
+
def handle_record_not_saved(error)
|
|
185
|
+
render_handled_error(:record_not_saved, message: error.message, errors: record_error_details(error))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def handle_record_not_destroyed(error)
|
|
189
|
+
render_handled_error(:record_not_destroyed, message: error.message, errors: record_error_details(error))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Optimistic locking (`lock_version`) lost the race. The raw message names
|
|
193
|
+
# the model class; the client only needs to know to reload and retry.
|
|
194
|
+
def handle_stale_object(_error)
|
|
195
|
+
render_handled_error(:stale_object, message: "Resource was modified by another request; reload and retry")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A unique index caught what a uniqueness validation raced past. The raw
|
|
199
|
+
# message is the adapter's SQL error — table and column included.
|
|
200
|
+
def handle_record_not_unique(_error)
|
|
201
|
+
render_handled_error(:record_not_unique, message: "Resource already exists")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def handle_invalid_foreign_key(_error)
|
|
205
|
+
render_handled_error(:foreign_key_violation, message: "Resource is referenced by other records")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Only raised with `config.action_controller.action_on_unpermitted_parameters = :raise`.
|
|
209
|
+
def handle_unpermitted_parameters(error)
|
|
210
|
+
names = error.respond_to?(:params) ? Array(error.params).map(&:to_s) : []
|
|
211
|
+
render_handled_error(:unpermitted_parameters,
|
|
212
|
+
message: "Unpermitted parameters: #{names.join(', ')}",
|
|
213
|
+
errors: names.empty? ? nil : names)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def handle_invalid_authenticity_token(_error)
|
|
217
|
+
render_handled_error(:invalid_authenticity_token, message: "Invalid authenticity token")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Malformed query string / form body (bad %-encoding, invalid UTF-8). The
|
|
221
|
+
# raw message echoes the offending input — never reflect it back.
|
|
222
|
+
def handle_bad_request(_error)
|
|
223
|
+
render_handled_error(:bad_request, message: "Bad request")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Unparseable JSON/XML request body. The raw message carries the parser's
|
|
227
|
+
# excerpt of the body.
|
|
228
|
+
def handle_parse_error(_error)
|
|
229
|
+
render_handled_error(:parse_error, message: "Malformed request body")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# `respond_to` had no block for the requested format.
|
|
233
|
+
def handle_unknown_format(_error)
|
|
234
|
+
render_handled_error(:unknown_format, message: "Requested format is not supported")
|
|
63
235
|
end
|
|
64
236
|
|
|
65
237
|
private
|
|
66
238
|
|
|
239
|
+
# Renders the envelope for a HANDLERS key: code = key, status from the table.
|
|
240
|
+
def render_handled_error(key, message:, errors: nil)
|
|
241
|
+
status = HANDLERS.fetch(key)[:status]
|
|
242
|
+
on_handled_error(key, @error_handleable_exception, status: status, message: message)
|
|
243
|
+
render_error_envelope(message: message, code: key.to_s, status: status, errors: errors)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def error_handleable_controller_name
|
|
247
|
+
respond_to?(:controller_path) ? controller_path : self.class.name
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def error_handleable_action_name
|
|
251
|
+
respond_to?(:action_name) ? action_name.to_s : nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Kept for subclasses that call it from a handler override.
|
|
67
255
|
def render_error_envelope(message:, code:, status:, errors: nil)
|
|
68
256
|
ConcernsOnRails::Support::ErrorEnvelope.render(
|
|
69
257
|
self, message: message, code: code, status: status, details: errors
|
|
70
258
|
)
|
|
71
259
|
end
|
|
260
|
+
|
|
261
|
+
def record_error_details(error)
|
|
262
|
+
error_messages_of(error.respond_to?(:record) ? error.record : nil)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# `errors.full_messages` when the object has any, else nil so the
|
|
266
|
+
# envelope omits `details` rather than emitting an empty array.
|
|
267
|
+
def error_messages_of(object)
|
|
268
|
+
return nil unless object.respond_to?(:errors)
|
|
269
|
+
|
|
270
|
+
messages = object.errors.full_messages
|
|
271
|
+
messages.empty? ? nil : messages
|
|
272
|
+
end
|
|
72
273
|
end
|
|
73
274
|
end
|
|
74
275
|
end
|
|
@@ -33,6 +33,7 @@ module ConcernsOnRails
|
|
|
33
33
|
extend ActiveSupport::Concern
|
|
34
34
|
|
|
35
35
|
RESET_PERIODS = %i[never year month day].freeze
|
|
36
|
+
ASSIGN_MODES = %i[create manual].freeze
|
|
36
37
|
NAME = "ConcernsOnRails::Models::Sequenceable".freeze
|
|
37
38
|
|
|
38
39
|
included do
|
|
@@ -54,26 +55,29 @@ module ConcernsOnRails
|
|
|
54
55
|
# scope: column or array of columns the counter is scoped to (default nil)
|
|
55
56
|
# reset: :never (default) | :year | :month | :day — restart per period (needs created_at)
|
|
56
57
|
# template: ->(seq, record) { ... } full custom formatter; overrides prefix/padding/period
|
|
58
|
+
# assign: :create (default) numbers every record in before_create; :manual leaves the
|
|
59
|
+
# column NULL until `assign_<field>!` — invoices numbered when finalized
|
|
57
60
|
def sequenceable_by(field = :sequence, into: nil, prefix: "", padding: 0,
|
|
58
|
-
separator: "-", start_at: 1, scope: nil, reset: :never, template: nil)
|
|
61
|
+
separator: "-", start_at: 1, scope: nil, reset: :never, template: nil, assign: :create)
|
|
59
62
|
field = field.to_sym
|
|
60
63
|
into = into&.to_sym
|
|
61
64
|
reset = reset.to_sym
|
|
65
|
+
assign = assign.to_sym
|
|
62
66
|
scope_cols = Array(scope).map(&:to_sym)
|
|
63
67
|
|
|
64
68
|
ensure_columns!(NAME, field, types: :integer)
|
|
65
69
|
ensure_columns!(NAME, into, types: :string) if into
|
|
66
70
|
ensure_columns!(NAME, *scope_cols) unless scope_cols.empty?
|
|
67
71
|
ensure_columns!(NAME, :created_at, types: :datetime) unless reset == :never
|
|
68
|
-
validate_sequenceable_options!(reset, template)
|
|
72
|
+
validate_sequenceable_options!(reset, template, assign)
|
|
69
73
|
|
|
70
74
|
self.sequenceable_config = sequenceable_config.merge(
|
|
71
75
|
field => { into: into, prefix: prefix.to_s, padding: padding.to_i,
|
|
72
76
|
separator: separator.to_s, start_at: start_at.to_i,
|
|
73
|
-
scope: scope_cols, reset: reset, template: template }
|
|
77
|
+
scope: scope_cols, reset: reset, template: template, assign: assign }
|
|
74
78
|
)
|
|
75
79
|
|
|
76
|
-
before_create -> { assign_sequenceable_value(field) }
|
|
80
|
+
before_create -> { assign_sequenceable_value(field) } if assign == :create
|
|
77
81
|
define_sequenceable_methods(field)
|
|
78
82
|
end
|
|
79
83
|
end
|
|
@@ -93,13 +97,21 @@ module ConcernsOnRails
|
|
|
93
97
|
define_singleton_method("next_#{field}") do |scope_attrs = {}|
|
|
94
98
|
sequence_base_value(field, nil, scope_attrs)
|
|
95
99
|
end
|
|
100
|
+
|
|
101
|
+
# On-demand numbering (the only way under assign: :manual), a
|
|
102
|
+
# predicate, and the "still awaiting a number" scope.
|
|
103
|
+
define_method("assign_#{field}!") { sequenceable_assign!(field) }
|
|
104
|
+
define_method("#{field}_assigned?") { self[field].present? }
|
|
105
|
+
scope "pending_#{field}", -> { where(field => nil) }
|
|
96
106
|
end
|
|
97
107
|
|
|
98
|
-
def validate_sequenceable_options!(reset, template)
|
|
108
|
+
def validate_sequenceable_options!(reset, template, assign = :create)
|
|
99
109
|
unless RESET_PERIODS.include?(reset)
|
|
100
110
|
raise ArgumentError, "#{NAME}: unknown reset '#{reset}'. Valid values: #{RESET_PERIODS.join(', ')}"
|
|
101
111
|
end
|
|
102
|
-
|
|
112
|
+
unless ASSIGN_MODES.include?(assign)
|
|
113
|
+
raise ArgumentError, "#{NAME}: unknown assign ':#{assign}'. Valid values: #{ASSIGN_MODES.join(', ')}"
|
|
114
|
+
end
|
|
103
115
|
return if template.nil? || template.respond_to?(:call)
|
|
104
116
|
|
|
105
117
|
raise ArgumentError, "#{NAME}: template must be callable (respond to #call)"
|
|
@@ -124,6 +136,19 @@ module ConcernsOnRails
|
|
|
124
136
|
self[cfg[:into]] = self.class.send(:format_sequence, field, self[field], self)
|
|
125
137
|
end
|
|
126
138
|
|
|
139
|
+
# Number the record now: the next value for its scope/period plus the
|
|
140
|
+
# into: string, save!d when the record is persisted and left for the
|
|
141
|
+
# caller's save when new. false (nothing rewritten) when already
|
|
142
|
+
# numbered, so a "finalize" action can be retried safely.
|
|
143
|
+
def sequenceable_assign!(field)
|
|
144
|
+
return false if self[field].present?
|
|
145
|
+
|
|
146
|
+
assign_sequenceable_value(field)
|
|
147
|
+
save! unless new_record?
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
private :sequenceable_assign!
|
|
151
|
+
|
|
127
152
|
# Pin the row inside the period its number is drawn from: with reset:
|
|
128
153
|
# enabled the period is computed from "now" during before_create, but
|
|
129
154
|
# created_at is stamped later, at INSERT time — across a year/month/day
|
|
@@ -17,10 +17,16 @@ module ConcernsOnRails
|
|
|
17
17
|
extend ActiveSupport::Concern
|
|
18
18
|
|
|
19
19
|
# instance methods
|
|
20
|
+
LABEL = "ConcernsOnRails::Models::Sluggable".freeze
|
|
21
|
+
|
|
20
22
|
included do
|
|
21
23
|
# declare class attributes and set default values
|
|
22
24
|
class_attribute :sluggable_field, instance_accessor: false
|
|
23
25
|
self.sluggable_field ||= :name
|
|
26
|
+
# `candidates:` — friendly_id slug candidates tried in order before the
|
|
27
|
+
# uuid fallback; `max_length:` — word-boundary truncation of the slug.
|
|
28
|
+
class_attribute :sluggable_candidates, instance_accessor: false, default: nil
|
|
29
|
+
class_attribute :sluggable_max_length, instance_accessor: false, default: nil
|
|
24
30
|
|
|
25
31
|
extend FriendlyId
|
|
26
32
|
|
|
@@ -32,6 +38,8 @@ module ConcernsOnRails
|
|
|
32
38
|
# we must override should_generate_new_friendly_id? to support update slug
|
|
33
39
|
# if we don't override this method, friendly_id will not generate the new slug when update
|
|
34
40
|
define_method :should_generate_new_friendly_id? do
|
|
41
|
+
return true if @sluggable_force_regenerate # regenerate_slug!
|
|
42
|
+
|
|
35
43
|
field = self.class.sluggable_field
|
|
36
44
|
slug_column = self.class.friendly_id_config.slug_column
|
|
37
45
|
|
|
@@ -49,6 +57,21 @@ module ConcernsOnRails
|
|
|
49
57
|
|
|
50
58
|
source_changed || slug_missing
|
|
51
59
|
end
|
|
60
|
+
|
|
61
|
+
# Defined on the class (like the method above) so it sits ABOVE
|
|
62
|
+
# FriendlyId::Slugged in the ancestor chain — a module-level override
|
|
63
|
+
# here would be shadowed by friendly_id's own. Truncates each candidate
|
|
64
|
+
# at a word (separator) boundary; the uniqueness suffix friendly_id
|
|
65
|
+
# appends on a conflict is added AFTER, on purpose — friendly_id's own
|
|
66
|
+
# `slug_limit` hard-cuts characters and squeezes the uuid inside the
|
|
67
|
+
# limit, which is rarely what a URL wants.
|
|
68
|
+
define_method :normalize_friendly_id do |value|
|
|
69
|
+
normalized = super(value)
|
|
70
|
+
limit = self.class.sluggable_max_length
|
|
71
|
+
return normalized unless limit && normalized.respond_to?(:truncate)
|
|
72
|
+
|
|
73
|
+
normalized.truncate(limit, omission: "", separator: friendly_id_config.sequence_separator)
|
|
74
|
+
end
|
|
52
75
|
end
|
|
53
76
|
|
|
54
77
|
# class methods
|
|
@@ -65,8 +88,13 @@ module ConcernsOnRails
|
|
|
65
88
|
# sluggable_by :title, scope: :account_id # slugs unique per scope column
|
|
66
89
|
# sluggable_by :title, reserved_words: %w[new] # block these slugs (a UUID is appended instead)
|
|
67
90
|
# sluggable_by :title, finders: true # Model.find accepts a slug directly
|
|
68
|
-
|
|
91
|
+
# sluggable_by :title, candidates: [:title, %i[title city]] # try "title", then "title-city", then a uuid
|
|
92
|
+
# sluggable_by :title, max_length: 60 # truncate at a word boundary
|
|
93
|
+
def sluggable_by(field, history: false, scope: nil, reserved_words: nil, finders: false,
|
|
94
|
+
candidates: nil, max_length: nil)
|
|
69
95
|
self.sluggable_field = field.to_sym
|
|
96
|
+
self.sluggable_candidates = sluggable_validate_candidates!(candidates)
|
|
97
|
+
self.sluggable_max_length = sluggable_validate_max_length!(max_length)
|
|
70
98
|
# Validate the slug column too (a missing one used to fail at first save
|
|
71
99
|
# with an opaque friendly_id error); an association scope: is exempt.
|
|
72
100
|
scope_column = scope && reflect_on_association(scope.to_sym) ? nil : scope
|
|
@@ -81,6 +109,28 @@ module ConcernsOnRails
|
|
|
81
109
|
|
|
82
110
|
private
|
|
83
111
|
|
|
112
|
+
# friendly_id's candidate shapes: a Symbol/String (method), a Proc, or an
|
|
113
|
+
# Array of those (joined with the separator).
|
|
114
|
+
def sluggable_validate_candidates!(candidates)
|
|
115
|
+
return nil if candidates.nil?
|
|
116
|
+
unless candidates.is_a?(Array) && candidates.any?
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"#{LABEL}: candidates: must be an Array of Symbols/Procs/Arrays (got #{candidates.inspect})"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
candidates
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def sluggable_validate_max_length!(max_length)
|
|
125
|
+
return nil if max_length.nil?
|
|
126
|
+
unless max_length.is_a?(Integer) && max_length.positive?
|
|
127
|
+
raise ArgumentError,
|
|
128
|
+
"#{LABEL}: max_length: must be a positive Integer (got #{max_length.inspect})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
max_length
|
|
132
|
+
end
|
|
133
|
+
|
|
84
134
|
# Re-runs friendly_id with the extra modules. friendly_id merges config
|
|
85
135
|
# across calls, so this layers :history / :scoped / :finders / :reserved onto :slugged.
|
|
86
136
|
def reconfigure_friendly_id(history:, scope:, reserved_words: nil, finders: false)
|
|
@@ -109,9 +159,22 @@ module ConcernsOnRails
|
|
|
109
159
|
# Example:
|
|
110
160
|
# record.slug_source
|
|
111
161
|
def slug_source
|
|
162
|
+
candidates = self.class.sluggable_candidates
|
|
163
|
+
return candidates if candidates
|
|
164
|
+
|
|
112
165
|
field = self.class.sluggable_field
|
|
113
166
|
respond_to?(field) ? send(field) : to_s
|
|
114
167
|
end
|
|
168
|
+
|
|
169
|
+
# Rebuild the slug from the current source (candidates included) and
|
|
170
|
+
# save — even over a slug that was assigned by hand, which a normal save
|
|
171
|
+
# deliberately leaves alone. Conflicts still get friendly_id's suffix.
|
|
172
|
+
def regenerate_slug!
|
|
173
|
+
@sluggable_force_regenerate = true
|
|
174
|
+
save!
|
|
175
|
+
ensure
|
|
176
|
+
@sluggable_force_regenerate = false
|
|
177
|
+
end
|
|
115
178
|
end
|
|
116
179
|
end
|
|
117
180
|
end
|
|
@@ -49,6 +49,9 @@ module ConcernsOnRails
|
|
|
49
49
|
# Example: Task.sortable_by(priority: :asc)
|
|
50
50
|
# A real module (not `class_methods do`) so the helpers aren't constrained
|
|
51
51
|
# by Metrics/BlockLength (the Stateable precedent).
|
|
52
|
+
LABEL = "ConcernsOnRails::Models::Sortable".freeze
|
|
53
|
+
MISSING_MODES = %i[append raise].freeze
|
|
54
|
+
|
|
52
55
|
module ClassMethods
|
|
53
56
|
include ConcernsOnRails::Support::ColumnGuard
|
|
54
57
|
|
|
@@ -83,8 +86,70 @@ module ConcernsOnRails
|
|
|
83
86
|
acts_as_list(list_options)
|
|
84
87
|
end
|
|
85
88
|
|
|
89
|
+
# Apply an explicit id order as positions — the "save this drag-and-drop
|
|
90
|
+
# order" operation acts_as_list lacks — in ONE UPDATE (`SET position =
|
|
91
|
+
# CASE id WHEN … END`) inside the current relation, in a transaction.
|
|
92
|
+
# Rows in the relation but not in `ids` are pushed after them in their
|
|
93
|
+
# current order (`missing: :append`) or make the call raise
|
|
94
|
+
# (`missing: :raise`); ids outside the relation, duplicates and an
|
|
95
|
+
# unknown `missing:` raise before anything is written. The first id gets
|
|
96
|
+
# the top position; on a :desc list it gets the highest value instead.
|
|
97
|
+
# Returns the number of rows updated. Bypasses acts_as_list callbacks by
|
|
98
|
+
# design (no per-row shifting).
|
|
99
|
+
def reposition!(ids, missing: :append)
|
|
100
|
+
unless MISSING_MODES.include?(missing)
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"#{LABEL}: missing: must be :append or :raise (got #{missing.inspect})"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
ordered = sortable_reposition_order(sortable_cast_ids(ids), missing)
|
|
106
|
+
return 0 if ordered.empty?
|
|
107
|
+
|
|
108
|
+
node = Arel::Nodes::Case.new(arel_table[primary_key])
|
|
109
|
+
sortable_positions_for(ordered).each { |id, position| node.when(id).then(position) }
|
|
110
|
+
transaction { unscoped.where(primary_key => ordered).update_all(sortable_field => node) }
|
|
111
|
+
end
|
|
112
|
+
|
|
86
113
|
private
|
|
87
114
|
|
|
115
|
+
# Params arrive as Strings; compare on the primary key's own type.
|
|
116
|
+
def sortable_cast_ids(ids)
|
|
117
|
+
type = type_for_attribute(primary_key)
|
|
118
|
+
Array(ids).map { |id| type.cast(id.respond_to?(:id) ? id.id : id) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The relation's members in the requested order, validated: no
|
|
122
|
+
# duplicates, nothing foreign, and the unlisted rest appended (or raised).
|
|
123
|
+
def sortable_reposition_order(ids, missing)
|
|
124
|
+
duplicates = ids.tally.select { |_id, n| n > 1 }.keys
|
|
125
|
+
raise ArgumentError, "#{LABEL}: duplicate id(s) #{duplicates.join(', ')} in ids" if duplicates.any?
|
|
126
|
+
|
|
127
|
+
current = all.reorder(sortable_field => sortable_direction, primary_key => :asc).pluck(primary_key)
|
|
128
|
+
unknown = ids - current
|
|
129
|
+
raise ArgumentError, "#{LABEL}: id(s) #{unknown.join(', ')} are not in this relation" if unknown.any?
|
|
130
|
+
|
|
131
|
+
rest = current - ids
|
|
132
|
+
if rest.any? && missing == :raise
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"#{LABEL}: #{rest.size} record(s) in this relation are missing from ids (pass missing: :append to push them after)"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
ids + rest
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# [[id, position], ...] from the top of the list; reversed for :desc so
|
|
141
|
+
# the first id sorts first.
|
|
142
|
+
def sortable_positions_for(ordered)
|
|
143
|
+
top = sortable_top_of_list
|
|
144
|
+
positions = Array.new(ordered.size) { |index| top + index }
|
|
145
|
+
positions.reverse! if sortable_direction == :desc
|
|
146
|
+
ordered.zip(positions)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sortable_top_of_list
|
|
150
|
+
method_defined?(:acts_as_list_top) ? new.acts_as_list_top : 1
|
|
151
|
+
end
|
|
152
|
+
|
|
88
153
|
def resolve_sortable_config(field_config, field_options)
|
|
89
154
|
if field_config.nil? && field_options.any?
|
|
90
155
|
# `sortable_by position: :desc` — the trailing keywords ARE the config.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.28.
|
|
4
|
+
version: 1.28.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|