concerns_on_rails 1.28.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f9aef0d5f95ffc23ef6213d5d95ea872a6146763d488a287a334db8be68b922
4
- data.tar.gz: b364f4276149a31b1ce1f6efaf170435900f34188eb358035c10020567e8b51d
3
+ metadata.gz: b0a05169c98ce90531027509dbbdd4f00722678e2cfe246a5692484d9a12a111
4
+ data.tar.gz: 10d2cebdced9beed7b677b18a0b8c85dc82d1125672ada2f5c5f12b995fa1933
5
5
  SHA512:
6
- metadata.gz: 025306bb013a418c8051db996ddd0fe6f2bf7ac7f657d22335639b5bcceecff8416e85c66ebde92a357656eeaba90f51a2993fab23232267af105e823cf28934
7
- data.tar.gz: b0d98d45a2b189b43985e5d5548c3b905a161f2aa0df9022d251a2788b62524360bc002d2981e7e9801a10e4b0f7aa4a7b1d6b0ee5adb4298edeef89a07a8467
6
+ metadata.gz: 756646b2232fa428250bf502fbb7ce87a44bf39830d9ae4810e50482315394fa1aa0b0e1ed47fb9a4b87d677d11507adfb7e14206f0b27119235060c74f1513b
7
+ data.tar.gz: '08b14db3a23711b0531447c773b2d831ec37fcf8c2b84fa59b4f203c7375923297e3a9dd93597b6e0091f18e677752be0dca67b913a4a3b1dd61fb042b417fad'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
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
+
59
+ ## 1.28.1 (2026-09-07)
60
+
61
+ One additive Paginatable option (#89): `pagination_meta` can now publish the
62
+ page window a pagination bar needs — first, last and N pages either side of the
63
+ current page — instead of leaving every client to compute it from
64
+ `total_pages`. Opt-in, controller-only, no extra query, and existing responses
65
+ are byte-for-byte unchanged. 1314 examples, 0 failures.
66
+
67
+ ### Added
68
+ - **Controllers::Paginatable**: `paginate_by window: 3` adds a `pages:` key to
69
+ `pagination_meta` — the first page, the last page, and N pages either side of
70
+ the current one, with the Symbol `:gap` standing in for each run left out
71
+ (`[1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100]`) — enough to render a
72
+ `1 … 44 45 46 [47] 48 49 50 … 100` bar straight from the meta Hash. Opt-in:
73
+ without `window:` the key is absent entirely (not `nil`), so no existing
74
+ meta Hash or serialized body changes shape, and `meta.key?(:pages)` is a
75
+ clean probe. The window is arithmetic over the `total` already counted, so it
76
+ costs no extra query on either the memoized `paginated` path or the fresh
77
+ `pagination_meta` one. A jump of exactly two pages is filled with the page it
78
+ would have hidden (`1 2 3`, never the wider `1 … 3`), and a `?page=` past the
79
+ last page windows around the last page as `rel="prev"` already does.
80
+ `window:` is validated at declaration — a non-negative Integer, or
81
+ `nil`/`false` to disable; `0` yields first, current and last only. Headers
82
+ and `Link` rels are unchanged: `pages:` is body-only by design. (#89)
83
+
84
+ ### Internal
85
+ - `set_pagination_headers` no longer splats the memoized meta Hash, which
86
+ raised `unknown keyword: :pages` once that Hash could carry the window; the
87
+ four header values are passed explicitly.
88
+ - README's advertised example counts were stale (1,160 and 1,303) and now read
89
+ 1,314.
90
+
3
91
  ## 1.28.0 (2026-09-06)
4
92
 
5
93
  Pagination is the theme: the four-PR Paginatable stack (#40, #45, #62, #83)
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,160 RSpec examples** run against a real database on every CI build
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
  ---
@@ -158,7 +158,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
158
158
  Add to your application's `Gemfile`:
159
159
 
160
160
  ```ruby
161
- gem "concerns_on_rails", "~> 1.26"
161
+ gem "concerns_on_rails", "~> 1.28"
162
162
  ```
163
163
 
164
164
  Or pull the latest from GitHub:
@@ -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
- - Direction values other than `:asc` / `:desc` silently fall back to `:asc`.
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.
@@ -1494,6 +1510,41 @@ paginate_by style: :jsonapi # ?page[numbe
1494
1510
  paginate_by page_param: %i[paging page], per_page_param: %i[paging per] # any nested path
1495
1511
  ```
1496
1512
 
1513
+ **Page window for a pagination bar**
1514
+
1515
+ `window:` adds a `pages:` key to `pagination_meta` — the first page, the last page, and N pages
1516
+ either side of the current one, with `:gap` standing in for the runs left out. It is opt-in:
1517
+ without `window:` the key is absent entirely. No extra query either way — it is arithmetic over
1518
+ the total already counted.
1519
+
1520
+ ```ruby
1521
+ paginate_by per_page: 10, window: 3
1522
+
1523
+ pagination_meta
1524
+ # => { total: 1000, page: 47, per_page: 10, total_pages: 100,
1525
+ # pages: [1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100] }
1526
+ ```
1527
+
1528
+ Render it straight into a `1 … 44 45 46 [47] 48 49 50 … 100` bar:
1529
+
1530
+ ```erb
1531
+ <% pagination_meta[:pages].each do |page| %>
1532
+ <%= page == :gap ? "…" : link_to(page, url_for(page: page)) %>
1533
+ <% end %>
1534
+ ```
1535
+
1536
+ | Situation | `pages:` |
1537
+ |-----------|----------|
1538
+ | `?page=47` of 100 | `[1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100]` |
1539
+ | `?page=2` of 100 | `[1, 2, 3, 4, 5, :gap, 100]` — no leading gap once the window reaches page 1 |
1540
+ | `?page=6` of 100 | `[1, 2, 3, 4, 5, 6, 7, 8, 9, :gap, 100]` — a one-page gap is filled, never `1 … 3` |
1541
+ | 5 pages total | `[1, 2, 3, 4, 5]` — the window spans everything |
1542
+ | empty collection | key absent |
1543
+ | `window: 0` | `[1, :gap, 47, :gap, 100]` — first, current and last only |
1544
+
1545
+ `?page=` past the last page windows around the last page (as `rel="prev"` already does), and
1546
+ `window:` must be a non-negative Integer or `nil`/`false` — validated at declaration.
1547
+
1497
1548
  **Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`, and an RFC 8288 `Link`
1498
1549
  header with `first` / `prev` / `next` / `last` URLs rebuilt from the current request (other query params
1499
1550
  preserved; `prev`/`next` only when such a page exists; nothing for an empty collection) — the GitHub
@@ -1530,7 +1581,7 @@ end
1530
1581
 
1531
1582
  **Notes**
1532
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).
1533
- - 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). They are **not signed**: 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.
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).
1534
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.
1535
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).
1536
1587
  - Use Paginatable when you need page numbers and totals.
@@ -1646,22 +1697,42 @@ end
1646
1697
 
1647
1698
  ## 🛟 ErrorHandleable
1648
1699
 
1649
- Install `rescue_from` handlers for the three most common controller exceptions and render them as the same JSON envelope used by Respondable.
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.
1650
1701
 
1651
1702
  ```ruby
1652
1703
  class Api::BaseController < ApplicationController
1653
1704
  include ConcernsOnRails::Controllers::Respondable # recommended
1654
1705
  include ConcernsOnRails::Controllers::ErrorHandleable
1706
+
1707
+ handle_errors except: :stale_object # optional — let some propagate
1655
1708
  end
1656
1709
  ```
1657
1710
 
1658
1711
  **Handled exceptions**
1659
1712
 
1660
- | Exception | Status | `code` |
1661
- |----------------------------------------|--------|-----------------------|
1662
- | `ActiveRecord::RecordNotFound` | 404 | `"not_found"` |
1663
- | `ActionController::ParameterMissing` | 400 | `"parameter_missing"` |
1664
- | `ActiveRecord::RecordInvalid` | 422 | `"record_invalid"` |
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.
1665
1736
 
1666
1737
  Response shape (matches `Respondable#render_error`):
1667
1738
 
@@ -1683,8 +1754,32 @@ class Api::BaseController < ApplicationController
1683
1754
  end
1684
1755
  ```
1685
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
+
1686
1779
  **Notes**
1687
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`.
1688
1783
  - `RecordInvalid.details` are populated from `error.record.errors.full_messages`.
1689
1784
 
1690
1785
  ---
@@ -2122,9 +2217,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2122
2217
 
2123
2218
  ```sh
2124
2219
  bundle install # install dev dependencies
2125
- bundle exec rspec # run the test suite (1,303 examples)
2220
+ bundle exec rspec # run the test suite (1,360 examples)
2126
2221
  gem build concerns_on_rails.gemspec # build the gem
2127
- gem install ./concerns_on_rails-1.28.0.gem # install locally
2222
+ gem install ./concerns_on_rails-1.28.2.gem # install locally
2128
2223
 
2129
2224
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2130
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 three most common controller
7
- # exceptions and renders them as the JSON error envelope used by Respondable.
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 → 404 not_found
16
- # * ActionController::ParameterMissing → 400 parameter_missing
17
- # * ActiveRecord::RecordInvalid → 422 record_invalid (with field errors)
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
- rescue_from "ActiveRecord::RecordNotFound", with: :handle_record_not_found
30
- rescue_from "ActionController::ParameterMissing", with: :handle_parameter_missing
31
- rescue_from "ActiveRecord::RecordInvalid", with: :handle_record_invalid
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
- render_error_envelope(
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
- render_error_envelope(
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
- record = error.respond_to?(:record) ? error.record : nil
55
- details = record.respond_to?(:errors) ? record.errors.full_messages : nil
56
-
57
- render_error_envelope(
58
- message: error.message,
59
- code: "record_invalid",
60
- status: :unprocessable_entity,
61
- errors: details
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
@@ -41,6 +41,9 @@ module ConcernsOnRails
41
41
  class_attribute :paginatable_per_page, default: DEFAULT_PER_PAGE
42
42
  class_attribute :paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
43
43
  class_attribute :paginatable_link_header, default: true
44
+ # Half-width of the page window in `pagination_meta[:pages]`; nil = no
45
+ # window, and no `pages:` key at all.
46
+ class_attribute :paginatable_window, default: nil
44
47
  # Where page / per_page are read from — a path of param names (`["page"]`,
45
48
  # or `["page", "number"]` for JSON:API's page[number]).
46
49
  class_attribute :paginatable_page_param, default: %w[page].freeze
@@ -55,10 +58,11 @@ module ConcernsOnRails
55
58
  # Example:
56
59
  # paginate_by per_page: 50, max_per_page: 500, link_header: false
57
60
  def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE, link_header: true,
58
- page_param: nil, per_page_param: nil, style: :flat)
61
+ page_param: nil, per_page_param: nil, style: :flat, window: nil)
59
62
  self.paginatable_per_page = per_page.to_i
60
63
  self.paginatable_max_per_page = max_per_page.to_i
61
64
  self.paginatable_link_header = link_header ? true : false
65
+ self.paginatable_window = paginatable_window!(window)
62
66
  defaults = paginatable_style_params!(style)
63
67
  self.paginatable_page_param = paginatable_param_path!(:page_param, page_param || defaults[0])
64
68
  self.paginatable_per_page_param = paginatable_param_path!(:per_page_param, per_page_param || defaults[1])
@@ -75,6 +79,15 @@ module ConcernsOnRails
75
79
  end
76
80
  end
77
81
 
82
+ # nil / false disable the window (no `pages:` key). `0` is meaningful:
83
+ # first, current and last only.
84
+ def paginatable_window!(value)
85
+ return nil if value.nil? || value == false
86
+ return value if value.is_a?(Integer) && !value.negative?
87
+
88
+ raise ArgumentError, "#{LABEL}: window: must be a non-negative Integer or nil (got #{value.inspect})"
89
+ end
90
+
78
91
  # A name or a non-empty path of names, normalized to Strings.
79
92
  def paginatable_param_path!(option, value)
80
93
  path = Array(value)
@@ -117,8 +130,9 @@ module ConcernsOnRails
117
130
  source.limit(per_page).offset(offset)
118
131
  end
119
132
 
120
- @paginatable_meta = { total: total, page: page, per_page: per_page, total_pages: total_pages }
121
- set_pagination_headers(**@paginatable_meta)
133
+ @paginatable_meta =
134
+ paginatable_windowed(total: total, page: page, per_page: per_page, total_pages: total_pages)
135
+ set_pagination_headers(total: total, page: page, per_page: per_page, total_pages: total_pages)
122
136
  set_pagination_links(page: page, total_pages: total_pages)
123
137
  records
124
138
  end
@@ -134,12 +148,12 @@ module ConcernsOnRails
134
148
 
135
149
  total = paginatable_meta_total(collection, total)
136
150
  per_page = pagination_per_page
137
- {
151
+ paginatable_windowed(
138
152
  total: total,
139
153
  page: pagination_page,
140
154
  per_page: per_page,
141
155
  total_pages: per_page.positive? ? (total.to_f / per_page).ceil : 0
142
- }
156
+ )
143
157
  end
144
158
 
145
159
  private
@@ -228,6 +242,39 @@ module ConcernsOnRails
228
242
  { path.first.to_sym => nested }
229
243
  end
230
244
 
245
+ # Adds `pages:` to a meta Hash when `window:` is declared, and leaves the
246
+ # Hash untouched otherwise — the key is absent, never nil, so
247
+ # `meta.key?(:pages)` is a clean opt-in probe.
248
+ def paginatable_windowed(meta)
249
+ pages = paginatable_page_window(meta[:page], meta[:total_pages])
250
+ pages ? meta.merge(pages: pages) : meta
251
+ end
252
+
253
+ # First, last, and `window` pages either side of the current page, with
254
+ # :gap standing in for the runs left out — [1, :gap, 46, 47, 48, :gap, 100].
255
+ # Pure arithmetic over the total already counted: no extra query. A page
256
+ # past the last one windows around the last page, as `prev` already does.
257
+ def paginatable_page_window(page, total_pages)
258
+ window = self.class.paginatable_window
259
+ return nil if window.nil? || total_pages < 1
260
+
261
+ current = page.clamp(1, total_pages)
262
+ from = [current - window, 1].max
263
+ to = [current + window, total_pages].min
264
+ paginatable_insert_gaps(([1, total_pages] + (from..to).to_a).uniq.sort)
265
+ end
266
+
267
+ # A jump of exactly two pages is filled with the page it would have
268
+ # hidden — "1 2 3", never the wider "1 … 3"; anything longer collapses
269
+ # into one :gap.
270
+ def paginatable_insert_gaps(numbers)
271
+ numbers.each_cons(2).with_object([numbers.first]) do |(previous, current), result|
272
+ result << (previous + 1) if current - previous == 2
273
+ result << :gap if current - previous > 2
274
+ result << current
275
+ end
276
+ end
277
+
231
278
  def set_pagination_headers(total:, page:, per_page:, total_pages:)
232
279
  return unless respond_to?(:response) && response
233
280
 
@@ -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
- def sluggable_by(field, history: false, scope: nil, reserved_words: nil, finders: false)
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.
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.28.0".freeze
2
+ VERSION = "1.28.2".freeze
3
3
  end
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.0
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-06 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack