concerns_on_rails 1.27.0 → 1.28.1

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: b06bf25720ff6c5c3a243efdb7b0d575351d50b8a9f33ae4778c1354816075bb
4
- data.tar.gz: 01dc0ff521bee7b5c74c9f30ddee414932441d2ceff84a15af1d9ae8e6d30ccc
3
+ metadata.gz: 00337457f7ea35fd2e02bb74bcb4031208109536946b448706104a9e031bd9ea
4
+ data.tar.gz: 3bfc8749303d212e51d98d8dd7fa4323888e3d984444c768695d5c290df311a3
5
5
  SHA512:
6
- metadata.gz: 37a533852c90cc1876519bd8b52fd07f165bfcfb02efca47bd1ed9d3ff86de94457755d57f9b986d244674a29992cead1b43698de7e3480e92276a0b9c9f1eda
7
- data.tar.gz: 3651f6a175bf1a1e203bbe8353a9fc8b7a2c6457dd19f73e89e31fa108a8e238f36a7004aeffface605800b2838f6bb935694ef823a65dc0be943763bfd31293
6
+ metadata.gz: 50ea50c59f6b15978fe3f1cc86f03266998249bd4b17dc84b36ff6a717e9a57d0efba37f2e5648c58009761aeb2198f14368b2c20715c0bc40c246c1e7b5ee25
7
+ data.tar.gz: f6b8c7784e26ac3f5c8f14217e07cbe6d846ecab216fad6c61fb192bf06a18aa4484d80efb32fd82d625c683d917d9fb8c9b88724adcf2a11878fb0ea6a1f7e9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.1 (2026-09-07)
4
+
5
+ One additive Paginatable option (#89): `pagination_meta` can now publish the
6
+ page window a pagination bar needs — first, last and N pages either side of the
7
+ current page — instead of leaving every client to compute it from
8
+ `total_pages`. Opt-in, controller-only, no extra query, and existing responses
9
+ are byte-for-byte unchanged. 1314 examples, 0 failures.
10
+
11
+ ### Added
12
+ - **Controllers::Paginatable**: `paginate_by window: 3` adds a `pages:` key to
13
+ `pagination_meta` — the first page, the last page, and N pages either side of
14
+ the current one, with the Symbol `:gap` standing in for each run left out
15
+ (`[1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100]`) — enough to render a
16
+ `1 … 44 45 46 [47] 48 49 50 … 100` bar straight from the meta Hash. Opt-in:
17
+ without `window:` the key is absent entirely (not `nil`), so no existing
18
+ meta Hash or serialized body changes shape, and `meta.key?(:pages)` is a
19
+ clean probe. The window is arithmetic over the `total` already counted, so it
20
+ costs no extra query on either the memoized `paginated` path or the fresh
21
+ `pagination_meta` one. A jump of exactly two pages is filled with the page it
22
+ would have hidden (`1 2 3`, never the wider `1 … 3`), and a `?page=` past the
23
+ last page windows around the last page as `rel="prev"` already does.
24
+ `window:` is validated at declaration — a non-negative Integer, or
25
+ `nil`/`false` to disable; `0` yields first, current and last only. Headers
26
+ and `Link` rels are unchanged: `pages:` is body-only by design. (#89)
27
+
28
+ ### Internal
29
+ - `set_pagination_headers` no longer splats the memoized meta Hash, which
30
+ raised `unknown keyword: :pages` once that Hash could carry the window; the
31
+ four header values are passed explicitly.
32
+ - README's advertised example counts were stale (1,160 and 1,303) and now read
33
+ 1,314.
34
+
35
+ ## 1.28.0 (2026-09-06)
36
+
37
+ Pagination is the theme: the four-PR Paginatable stack (#40, #45, #62, #83)
38
+ from the September enhancement loop. Paginatable now paginates in-memory
39
+ collections, both paginators emit RFC 8288 `Link` headers, results that were
40
+ already paginated upstream can pass `total:`, and the page / per-page parameter
41
+ names (including JSON:API's `page[number]` / `page[size]`) are configurable.
42
+ Controller-only: no new columns, migrations or runtime dependencies. 1303
43
+ examples, 0 failures.
44
+
45
+ ### Added
46
+ - **Controllers::Paginatable**: `paginated` / `pagination_meta` accept in-memory
47
+ collections — an `Array`, `Set`, `Range` or any other non-Hash `Enumerable` —
48
+ not only ActiveRecord relations. The collection is materialized once, the
49
+ total is its size and the current page comes back as an `Array` with the same
50
+ `X-Total-Count` / `X-Page` / `X-Per-Page` / `X-Total-Pages` headers and
51
+ memoized meta. Relations still paginate in SQL. A `Hash`, `nil` or a
52
+ non-collection raises `ArgumentError` naming the class received. (#40)
53
+ - **Controllers::Paginatable / CursorPaginatable**: RFC 8288 `Link` response
54
+ header — `first`/`prev`/`next`/`last` page URLs for Paginatable, `next`
55
+ (+ `prev` in bidirectional mode, `first` once a cursor is in play) for
56
+ CursorPaginatable — rebuilt from the current request with every other query
57
+ param preserved and appended to any existing `Link` header. Opt out with
58
+ `link_header: false` on either macro. Backed by the new shared
59
+ `Support::LinkHeader`. (#45)
60
+ - **Controllers::Paginatable**: `paginated(collection, total:)` /
61
+ `pagination_meta(total:)` for collections that are already one page — an
62
+ external API's or search service's page N plus its total: nothing is sliced
63
+ or counted, and `total` drives the `X-*` headers and the `Link` header.
64
+ `total:` must be a non-negative Integer. (#62)
65
+ - **Controllers::Paginatable**: `paginate_by page_param:` / `per_page_param:`
66
+ (a top-level name or a nested path such as `%i[page number]`) and
67
+ `style: :jsonapi` (`?page[number]=&page[size]=`); explicit `*_param:` options
68
+ win over the style, anything else raises at declaration. Readers dig the path
69
+ through `ScalarParam`, so a scalar where a Hash is expected falls back to the
70
+ default instead of raising. The `Link` header URLs are rebuilt under the
71
+ configured names (nested paths Rack-encoded, sibling keys preserved). (#83)
72
+
73
+ ### Notes
74
+ The `Link` header is **on by default**, so every non-empty paginated response
75
+ grows by one header. Its URLs are built from `request.base_url` + `request.path`,
76
+ i.e. whatever host Rails sees — behind a proxy configure `trusted_proxies` /
77
+ forwarded headers, or pass `link_header: false`. The `X-*` headers are
78
+ unchanged either way, and the existing `?page=&per_page=` behaviour is
79
+ byte-for-byte the same when no `page_param:` / `per_page_param:` / `style:` is
80
+ given.
81
+
82
+ ### Internal
83
+ - New `Support::LinkHeader` (autoloaded): `available?(controller)` — a request
84
+ exposing `base_url` / `path` / `query_parameters` (the dependency-free
85
+ `FakeController` has none, so emission is silently skipped); `url_for(request,
86
+ drop:, **overrides)` — Rack nested-query encoding, nested params survive, Hash
87
+ override values deep-stringified, `nil` / `drop:` removes a key;
88
+ `append(response, rels)` — never clobbers an existing `Link`.
89
+ - `Paginatable.paginate_by` moved into `module ClassMethods` alongside its new
90
+ private helpers (RuboCop scope rule; the Stateable precedent).
91
+ - Development dependencies: simplecov `~> 1.1`, sqlite3 `~> 2.9.6`, rubocop
92
+ `~> 1.89`; GitHub Actions `checkout` v7, `configure-pages` v6, `deploy-pages`
93
+ v5, `upload-pages-artifact` v5. The lockfile now resolves `permittable` 0.2.0
94
+ (the `~> 0.1` runtime pin is unchanged).
95
+
3
96
  ## 1.27.0 (2026-08-29)
4
97
 
5
98
  Scope-name collisions finally have an escape hatch on the eight concerns whose
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,314 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:
@@ -1455,6 +1455,30 @@ class ArticlesController < ApplicationController
1455
1455
  end
1456
1456
  ```
1457
1457
 
1458
+ **Arrays and other Enumerables work too.** Results that never touched the database — an
1459
+ external API response, a loaded association, a hand-built list of Structs — get the same
1460
+ slicing, headers and `pagination_meta`. Relations still paginate in SQL (`LIMIT`/`OFFSET`);
1461
+ an in-memory collection is sliced in Ruby and comes back as an `Array`:
1462
+
1463
+ ```ruby
1464
+ def search
1465
+ render json: paginated(ExternalCatalog.search(params[:q])) # Array in, current page out
1466
+ end
1467
+ ```
1468
+
1469
+ Anything answering `limit`/`offset` is treated as a relation; any other non-`Hash` `Enumerable`
1470
+ (`Array`, `Set`, `Range`, `Enumerator` — consumed once) is materialized and sliced. A `Hash`,
1471
+ `nil` or a non-collection raises `ArgumentError` (call `.to_a` to paginate a Hash's pairs).
1472
+
1473
+ **Already paginated upstream?** When an external API or search service hands you page N and the
1474
+ total it counted, pass `total:` — nothing is sliced, limited or counted; the collection comes back
1475
+ as-is and `total` drives `X-Total-Count`, `X-Total-Pages` and the `Link` header:
1476
+
1477
+ ```ruby
1478
+ result = Catalog.search(params[:q], page: params[:page], per_page: params[:per_page])
1479
+ render_success(data: paginated(result.hits, total: result.total_hits), meta: pagination_meta)
1480
+ ```
1481
+
1458
1482
  **URL params**
1459
1483
 
1460
1484
  | Param | Default | Notes |
@@ -1462,7 +1486,54 @@ end
1462
1486
  | `?page=` | `1` | Page numbers below 1 are clamped to 1 |
1463
1487
  | `?per_page=` | `25` | Capped at `max_per_page` (default 200) |
1464
1488
 
1465
- **Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`.
1489
+ Rename them, or speak JSON:API — the `Link` header URLs follow whatever you pick:
1490
+
1491
+ ```ruby
1492
+ paginate_by page_param: :p, per_page_param: :limit # ?p=2&limit=10
1493
+ paginate_by style: :jsonapi # ?page[number]=2&page[size]=10
1494
+ paginate_by page_param: %i[paging page], per_page_param: %i[paging per] # any nested path
1495
+ ```
1496
+
1497
+ **Page window for a pagination bar**
1498
+
1499
+ `window:` adds a `pages:` key to `pagination_meta` — the first page, the last page, and N pages
1500
+ either side of the current one, with `:gap` standing in for the runs left out. It is opt-in:
1501
+ without `window:` the key is absent entirely. No extra query either way — it is arithmetic over
1502
+ the total already counted.
1503
+
1504
+ ```ruby
1505
+ paginate_by per_page: 10, window: 3
1506
+
1507
+ pagination_meta
1508
+ # => { total: 1000, page: 47, per_page: 10, total_pages: 100,
1509
+ # pages: [1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100] }
1510
+ ```
1511
+
1512
+ Render it straight into a `1 … 44 45 46 [47] 48 49 50 … 100` bar:
1513
+
1514
+ ```erb
1515
+ <% pagination_meta[:pages].each do |page| %>
1516
+ <%= page == :gap ? "…" : link_to(page, url_for(page: page)) %>
1517
+ <% end %>
1518
+ ```
1519
+
1520
+ | Situation | `pages:` |
1521
+ |-----------|----------|
1522
+ | `?page=47` of 100 | `[1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100]` |
1523
+ | `?page=2` of 100 | `[1, 2, 3, 4, 5, :gap, 100]` — no leading gap once the window reaches page 1 |
1524
+ | `?page=6` of 100 | `[1, 2, 3, 4, 5, 6, 7, 8, 9, :gap, 100]` — a one-page gap is filled, never `1 … 3` |
1525
+ | 5 pages total | `[1, 2, 3, 4, 5]` — the window spans everything |
1526
+ | empty collection | key absent |
1527
+ | `window: 0` | `[1, :gap, 47, :gap, 100]` — first, current and last only |
1528
+
1529
+ `?page=` past the last page windows around the last page (as `rel="prev"` already does), and
1530
+ `window:` must be a non-negative Integer or `nil`/`false` — validated at declaration.
1531
+
1532
+ **Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`, and an RFC 8288 `Link`
1533
+ header with `first` / `prev` / `next` / `last` URLs rebuilt from the current request (other query params
1534
+ preserved; `prev`/`next` only when such a page exists; nothing for an empty collection) — the GitHub
1535
+ convention, so clients follow links instead of computing page numbers. Appended to any `Link` header
1536
+ already set (Deprecatable, CDN hints). `paginate_by link_header: false` turns it off.
1466
1537
 
1467
1538
  ---
1468
1539
 
@@ -1490,7 +1561,7 @@ end
1490
1561
  | `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the cap) |
1491
1562
  | `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
1492
1563
 
1493
- **Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`.
1564
+ **Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`. Plus an RFC 8288 `Link` header: `rel="next"` carries the next-cursor URL, `rel="prev"` the prev-cursor URL (bidirectional), `rel="first"` the current URL with the cursor dropped (once a cursor is in play); `per_page` and the order preset are preserved. `cursor_paginate_by link_header: false` turns it off.
1494
1565
 
1495
1566
  **Notes**
1496
1567
  - 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).
@@ -2086,9 +2157,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2086
2157
 
2087
2158
  ```sh
2088
2159
  bundle install # install dev dependencies
2089
- bundle exec rspec # run the test suite (1,245 examples)
2160
+ bundle exec rspec # run the test suite (1,314 examples)
2090
2161
  gem build concerns_on_rails.gemspec # build the gem
2091
- gem install ./concerns_on_rails-1.27.0.gem # install locally
2162
+ gem install ./concerns_on_rails-1.28.1.gem # install locally
2092
2163
 
2093
2164
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2094
2165
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -2,6 +2,7 @@ require "active_support/concern"
2
2
  require "concerns_on_rails/support/error_envelope"
3
3
  require "concerns_on_rails/support/scalar_param"
4
4
  require "json"
5
+ require "concerns_on_rails/support/link_header"
5
6
  require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
6
7
 
7
8
  module ConcernsOnRails
@@ -141,6 +142,7 @@ module ConcernsOnRails
141
142
  class_attribute :cursor_paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
142
143
  class_attribute :cursor_paginatable_bidirectional, default: false
143
144
  class_attribute :cursor_paginatable_predicate, default: :auto
145
+ class_attribute :cursor_paginatable_link_header, default: true
144
146
 
145
147
  # Real controllers (anything with ActiveSupport::Rescuable) get the 400
146
148
  # handlers automatically; bare objects let the errors propagate.
@@ -161,7 +163,7 @@ module ConcernsOnRails
161
163
  # max_per_page: 0 (or negative) disables the per_page cap.
162
164
  def cursor_paginate_by(order: nil, order_presets: nil, default_preset: nil, order_param: :order,
163
165
  per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE,
164
- bidirectional: false, predicate: :auto)
166
+ bidirectional: false, predicate: :auto, link_header: true)
165
167
  self.cursor_paginatable_order = order && CursorPaginatable.normalize_order!(order)
166
168
  self.cursor_paginatable_order_presets = order_presets && CursorPaginatable.normalize_presets!(order_presets)
167
169
  self.cursor_paginatable_default_preset =
@@ -171,6 +173,7 @@ module ConcernsOnRails
171
173
  self.cursor_paginatable_max_per_page = max_per_page.to_i
172
174
  self.cursor_paginatable_bidirectional = bidirectional ? true : false
173
175
  self.cursor_paginatable_predicate = CursorPaginatable.validate_predicate!(predicate)
176
+ self.cursor_paginatable_link_header = link_header ? true : false
174
177
  end
175
178
  end
176
179
 
@@ -506,11 +509,31 @@ module ConcernsOnRails
506
509
  response.set_header("X-Count", meta[:count].to_s)
507
510
  response.set_header("X-Has-More", meta[:has_more].to_s)
508
511
  response.set_header("X-Next-Cursor", meta[:next_cursor]) if meta[:next_cursor]
512
+ apply_cursor_pagination_links(meta)
509
513
  return unless meta.key?(:has_prev)
510
514
 
511
515
  response.set_header("X-Has-Prev", meta[:has_prev].to_s)
512
516
  response.set_header("X-Prev-Cursor", meta[:prev_cursor]) if meta[:prev_cursor]
513
517
  end
518
+
519
+ # RFC 8288 Link: rel="next" carries the X-Next-Cursor token, rel="prev"
520
+ # the X-Prev-Cursor one (bidirectional mode), rel="first" is the current
521
+ # URL with the cursor dropped — emitted once a cursor is in play. Other
522
+ # query params (per_page, the order preset) are preserved. Skipped when
523
+ # disabled or without a real request.
524
+ def apply_cursor_pagination_links(meta)
525
+ return unless self.class.cursor_paginatable_link_header
526
+ return unless ConcernsOnRails::Support::LinkHeader.available?(self)
527
+
528
+ cursor_url = ->(token) { ConcernsOnRails::Support::LinkHeader.url_for(request, cursor: token) }
529
+ raw_cursor = params[:cursor]
530
+ ConcernsOnRails::Support::LinkHeader.append(
531
+ response,
532
+ first: ConcernsOnRails::Support::ScalarParam.scalar?(raw_cursor) && !raw_cursor.to_s.empty? ? cursor_url.call(nil) : nil,
533
+ prev: meta[:prev_cursor] && cursor_url.call(meta[:prev_cursor]),
534
+ next: meta[:next_cursor] && cursor_url.call(meta[:next_cursor])
535
+ )
536
+ end
514
537
  end
515
538
  end
516
539
  end
@@ -1,5 +1,6 @@
1
1
  require "active_support/concern"
2
2
  require "concerns_on_rails/support/scalar_param"
3
+ require "concerns_on_rails/support/link_header"
3
4
 
4
5
  module ConcernsOnRails
5
6
  module Controllers
@@ -14,95 +15,266 @@ module ConcernsOnRails
14
15
  # render json: paginated(Article.all)
15
16
  # end
16
17
  # end
18
+ #
19
+ # `paginated` also takes an in-memory collection — an Array, Set, Range or
20
+ # any other non-Hash Enumerable — so results assembled outside the
21
+ # database (an external API, a loaded association, a hand-built list of
22
+ # Structs) get the same slicing, headers and `pagination_meta`:
23
+ #
24
+ # def search
25
+ # render json: paginated(ExternalCatalog.search(params[:q]))
26
+ # end
27
+ #
28
+ # Every paginated response also carries an RFC 8288 `Link` header with
29
+ # first/prev/next/last URLs rebuilt from the current request (other query
30
+ # params preserved) — the GitHub convention, so clients can follow links
31
+ # instead of computing page numbers. `paginate_by link_header: false` turns
32
+ # it off.
17
33
  module Paginatable
18
34
  extend ActiveSupport::Concern
19
35
 
36
+ LABEL = "ConcernsOnRails::Controllers::Paginatable".freeze
20
37
  DEFAULT_PER_PAGE = 25
21
38
  DEFAULT_MAX_PER_PAGE = 200
22
39
 
23
40
  included do
24
41
  class_attribute :paginatable_per_page, default: DEFAULT_PER_PAGE
25
42
  class_attribute :paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
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
47
+ # Where page / per_page are read from — a path of param names (`["page"]`,
48
+ # or `["page", "number"]` for JSON:API's page[number]).
49
+ class_attribute :paginatable_page_param, default: %w[page].freeze
50
+ class_attribute :paginatable_per_page_param, default: %w[per_page].freeze
26
51
  end
27
52
 
28
- class_methods do
29
- # Configure the default page size and the hard cap on per_page.
53
+ # A real module (not `class_methods do`) so the macro and its private
54
+ # helpers share one `private` without tripping RuboCop's scope analysis.
55
+ module ClassMethods
56
+ # Configure the default page size, the hard cap on per_page, and whether
57
+ # the RFC 8288 Link header is emitted.
30
58
  # Example:
31
- # paginate_by per_page: 50, max_per_page: 500
32
- def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE)
59
+ # paginate_by per_page: 50, max_per_page: 500, link_header: false
60
+ def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE, link_header: true,
61
+ page_param: nil, per_page_param: nil, style: :flat, window: nil)
33
62
  self.paginatable_per_page = per_page.to_i
34
63
  self.paginatable_max_per_page = max_per_page.to_i
64
+ self.paginatable_link_header = link_header ? true : false
65
+ self.paginatable_window = paginatable_window!(window)
66
+ defaults = paginatable_style_params!(style)
67
+ self.paginatable_page_param = paginatable_param_path!(:page_param, page_param || defaults[0])
68
+ self.paginatable_per_page_param = paginatable_param_path!(:per_page_param, per_page_param || defaults[1])
69
+ end
70
+
71
+ private
72
+
73
+ # :flat → page / per_page; :jsonapi → page[number] / page[size].
74
+ def paginatable_style_params!(style)
75
+ case style.to_sym
76
+ when :flat then [%w[page], %w[per_page]]
77
+ when :jsonapi then [%w[page number], %w[page size]]
78
+ else raise ArgumentError, "#{LABEL}: style: must be :flat or :jsonapi (got #{style.inspect})"
79
+ end
80
+ end
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
+
91
+ # A name or a non-empty path of names, normalized to Strings.
92
+ def paginatable_param_path!(option, value)
93
+ path = Array(value)
94
+ valid = path.any? && path.all? { |segment| (segment.is_a?(Symbol) || segment.is_a?(String)) && !segment.to_s.empty? }
95
+ raise ArgumentError, "#{LABEL}: #{option}: must be a param name or a path of names (got #{value.inspect})" unless valid
96
+
97
+ path.map(&:to_s).freeze
35
98
  end
36
99
  end
37
100
 
38
- # Apply pagination to a relation and set the standard response headers.
39
- # Returns the paginated relation; the metadata is memoized so a follow-up
40
- # `pagination_meta` (no argument) reuses it. Safe on empty relations.
41
- def paginated(relation)
101
+ # Apply pagination to a relation or an in-memory collection and set the
102
+ # standard response headers. A relation comes back as a relation with
103
+ # LIMIT/OFFSET applied (still lazy); an Enumerable comes back as the
104
+ # current page's Array slice (`[]` past the last page). The metadata is
105
+ # memoized so a follow-up `pagination_meta` (no argument) reuses it.
106
+ # Safe on empty collections.
107
+ #
108
+ # `total:` says the collection IS the current page already — an external
109
+ # API or search service returned page N of a result set it counted for
110
+ # you. Nothing is sliced, limited or counted: the records come back
111
+ # untouched and `total` drives X-Total-Count, X-Total-Pages and the Link
112
+ # header. Ask the upstream for the same page/per_page you read here.
113
+ def paginated(collection, total: nil)
42
114
  @paginatable_meta = nil
115
+ source = paginatable_source(collection)
116
+ pre_paginated = !total.nil?
43
117
  page = pagination_page
44
118
  per_page = pagination_per_page
45
119
  offset = (page - 1) * per_page
46
120
 
47
- total = paginatable_total(relation)
121
+ total = pre_paginated ? paginatable_validate_total!(total) : paginatable_total(source)
48
122
  total_pages = per_page.positive? ? (total.to_f / per_page).ceil : 0
49
123
 
50
- records = relation.limit(per_page).offset(offset)
124
+ records =
125
+ if pre_paginated
126
+ source # the caller already fetched exactly this page: an Array stays an Array, a relation is not limited
127
+ elsif source.is_a?(Array)
128
+ source[offset, per_page] || []
129
+ else
130
+ source.limit(per_page).offset(offset)
131
+ end
51
132
 
52
- @paginatable_meta = { total: total, page: page, per_page: per_page, total_pages: total_pages }
53
- 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)
136
+ set_pagination_links(page: page, total_pages: total_pages)
54
137
  records
55
138
  end
56
139
 
57
- # Pagination metadata WITHOUT applying limit/offset — handy for
58
- # body-based pagination (compose with Respondable's `meta:`). Call with
59
- # no argument after `paginated` to reuse its memoized meta — the
140
+ # Pagination metadata WITHOUT applying limit/offset (or slicing) — handy
141
+ # for body-based pagination (compose with Respondable's `meta:`). Call
142
+ # with no argument after `paginated` to reuse its memoized meta — the
60
143
  # documented records+meta composition used to run the identical COUNT
61
- # twice per request. Pass a relation to compute fresh.
62
- def pagination_meta(relation = nil)
63
- return @paginatable_meta if relation.nil? && @paginatable_meta
64
-
65
- if relation.nil?
66
- raise ArgumentError,
67
- "ConcernsOnRails::Controllers::Paginatable: pagination_meta needs a relation " \
68
- "(no prior paginated call in this request to reuse)"
69
- end
144
+ # twice per request. Pass a relation or collection to compute fresh.
145
+ # With `total:` the COUNT is skipped (and the collection may be omitted).
146
+ def pagination_meta(collection = nil, total: nil)
147
+ return @paginatable_meta if collection.nil? && total.nil? && @paginatable_meta
70
148
 
71
- total = paginatable_total(relation)
149
+ total = paginatable_meta_total(collection, total)
72
150
  per_page = pagination_per_page
73
- {
151
+ paginatable_windowed(
74
152
  total: total,
75
153
  page: pagination_page,
76
154
  per_page: per_page,
77
155
  total_pages: per_page.positive? ? (total.to_f / per_page).ceil : 0
78
- }
156
+ )
79
157
  end
80
158
 
81
159
  private
82
160
 
83
- # COUNT with the clauses that break or skew it stripped: order/limit/
84
- # offset are irrelevant, a custom SELECT list would turn into the invalid
85
- # COUNT(a, b), and count(:all) keeps DISTINCT semantics. A grouped
86
- # relation counts as a Hash (group => count); the meaningful total is the
87
- # number of groups.
88
- def paginatable_total(relation)
89
- counted = relation.except(:order, :limit, :offset, :select).count(:all)
161
+ # Relations — anything answering `limit` and `offset`: an
162
+ # ActiveRecord::Relation, an association CollectionProxy, a model class —
163
+ # pass through untouched so they keep paginating in SQL. Any other
164
+ # non-Hash Enumerable is materialized ONCE into an Array, so an
165
+ # Enumerator is not consumed twice (once to count, once to slice). A Hash
166
+ # is rejected rather than silently paginated as [key, value] pairs.
167
+ def paginatable_source(collection)
168
+ return collection if collection.respond_to?(:limit) && collection.respond_to?(:offset)
169
+ return collection.to_a if collection.is_a?(Enumerable) && !collection.is_a?(Hash)
170
+
171
+ hint = collection.is_a?(Hash) ? " — call .to_a to paginate a Hash as [key, value] pairs" : ""
172
+ raise ArgumentError,
173
+ "#{LABEL}: expected an ActiveRecord relation or an Enumerable (Array, Set, Range, ...), " \
174
+ "got #{collection.class}#{hint}"
175
+ end
176
+
177
+ # `total:` wins (validated); otherwise COUNT the collection; neither
178
+ # given and nothing memoized is a caller error.
179
+ def paginatable_meta_total(collection, total)
180
+ return paginatable_validate_total!(total) unless total.nil?
181
+ return paginatable_total(paginatable_source(collection)) unless collection.nil?
182
+
183
+ raise ArgumentError,
184
+ "#{LABEL}: pagination_meta needs a relation or collection " \
185
+ "(no prior paginated call in this request to reuse)"
186
+ end
187
+
188
+ def paginatable_validate_total!(total)
189
+ return total if total.is_a?(Integer) && total >= 0
190
+
191
+ raise ArgumentError, "#{LABEL}: total: must be a non-negative Integer (got #{total.inspect})"
192
+ end
193
+
194
+ # Arrays already know their size. Relations COUNT with the clauses that
195
+ # break or skew it stripped: order/limit/offset are irrelevant, a custom
196
+ # SELECT list would turn into the invalid COUNT(a, b), and count(:all)
197
+ # keeps DISTINCT semantics. A grouped relation counts as a Hash
198
+ # (group => count); the meaningful total is the number of groups.
199
+ def paginatable_total(source)
200
+ return source.size if source.is_a?(Array)
201
+
202
+ counted = source.except(:order, :limit, :offset, :select).count(:all)
90
203
  counted.is_a?(Hash) ? counted.length : counted
91
204
  end
92
205
 
93
206
  # Both readers route through ScalarParam: `?page[]=1` / `?page[x]=1`
94
207
  # arrive as Array/Parameters, and calling .to_i on those was a 500.
95
208
  def pagination_page
96
- [ConcernsOnRails::Support::ScalarParam.to_i(params[:page], default: 0), 1].max
209
+ [ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0), 1].max
97
210
  end
98
211
 
99
212
  def pagination_per_page
100
- requested = ConcernsOnRails::Support::ScalarParam.to_i(params[:per_page], default: 0)
213
+ requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_per_page_param), default: 0)
101
214
  requested = self.class.paginatable_per_page if requested < 1
102
215
  cap = self.class.paginatable_max_per_page
103
216
  cap.positive? ? [requested, cap].min : requested
104
217
  end
105
218
 
219
+ # Dig the configured path out of params: `["page"]` → params[:page];
220
+ # `["page", "number"]` → params[:page][:number]. A scalar where a Hash
221
+ # is expected yields nil (→ the default), like any other garbage.
222
+ def pagination_param(path)
223
+ path.reduce(params) do |node, key|
224
+ break nil unless node.respond_to?(:[]) && !node.is_a?(String) && !node.is_a?(Array)
225
+
226
+ node[key]
227
+ end
228
+ end
229
+
230
+ # The `overrides` for LinkHeader.url_for that set the page number under the
231
+ # configured name — replacing the whole nested Hash for a path so the
232
+ # other keys in it (page[size]) survive.
233
+ def pagination_page_override(number)
234
+ path = self.class.paginatable_page_param
235
+ return { path.first.to_sym => number } if path.size == 1
236
+
237
+ nested = request.query_parameters.to_h.transform_keys(&:to_s)[path.first]
238
+ nested = nested.is_a?(Hash) ? nested.deep_dup : {}
239
+ node = nested
240
+ path[1...-1].each { |key| node = (node[key] = node[key].is_a?(Hash) ? node[key] : {}) }
241
+ node[path.last] = number
242
+ { path.first.to_sym => nested }
243
+ end
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
+
106
278
  def set_pagination_headers(total:, page:, per_page:, total_pages:)
107
279
  return unless respond_to?(:response) && response
108
280
 
@@ -111,6 +283,30 @@ module ConcernsOnRails
111
283
  response.set_header("X-Per-Page", per_page.to_s)
112
284
  response.set_header("X-Total-Pages", total_pages.to_s)
113
285
  end
286
+
287
+ # Link: <…?page=1>; rel="first", <…?page=1>; rel="prev", <…?page=3>;
288
+ # rel="next", <…?page=5>; rel="last". prev/next only when such a page
289
+ # exists; past the end, prev points at the last page. Nothing is emitted
290
+ # for an empty collection, when disabled, or without a real request.
291
+ def set_pagination_links(page:, total_pages:)
292
+ return unless pagination_links_applicable?(total_pages)
293
+
294
+ page_url = ->(number) { ConcernsOnRails::Support::LinkHeader.url_for(request, **pagination_page_override(number)) }
295
+ ConcernsOnRails::Support::LinkHeader.append(
296
+ response,
297
+ first: page_url.call(1),
298
+ prev: page > 1 ? page_url.call([page - 1, total_pages].min) : nil,
299
+ next: page < total_pages ? page_url.call(page + 1) : nil,
300
+ last: page_url.call(total_pages)
301
+ )
302
+ end
303
+
304
+ def pagination_links_applicable?(total_pages)
305
+ return false unless self.class.paginatable_link_header && total_pages.positive?
306
+ return false unless respond_to?(:response) && response
307
+
308
+ ConcernsOnRails::Support::LinkHeader.available?(self)
309
+ end
114
310
  end
115
311
  end
116
312
  end
@@ -0,0 +1,54 @@
1
+ require "rack/utils"
2
+
3
+ module ConcernsOnRails
4
+ module Support
5
+ # RFC 8288 `Link` response header for the paginators: rebuilds the current
6
+ # request URL with a few query params changed (`page=3`, `cursor=…`) and
7
+ # appends `<url>; rel="next"` entries to the response — never clobbering a
8
+ # Link header something else already set (Deprecatable's rel="deprecation",
9
+ # CDN preload hints). Shared by Paginatable and CursorPaginatable.
10
+ module LinkHeader
11
+ module_function
12
+
13
+ # True when the controller has a request the URLs can be rebuilt from.
14
+ # The dependency-free FakeController has none, so emission is skipped.
15
+ def available?(controller)
16
+ return false unless controller.respond_to?(:request)
17
+
18
+ request = controller.request
19
+ %i[base_url path query_parameters].all? { |reader| request.respond_to?(reader) }
20
+ end
21
+
22
+ # The request's URL with `overrides` merged into its query string (a nil
23
+ # value or a key in `drop:` removes that param; a Hash value replaces a
24
+ # nested param wholesale — `page: { "number" => 3, "size" => 10 }`).
25
+ # Existing params keep their order; nested params survive via Rack's
26
+ # nested-query encoding.
27
+ def url_for(request, drop: [], **overrides)
28
+ query = request.query_parameters.to_h.transform_keys(&:to_s)
29
+ overrides.each { |key, value| value.nil? ? query.delete(key.to_s) : query[key.to_s] = stringify_deep(value) }
30
+ Array(drop).each { |key| query.delete(key.to_s) }
31
+
32
+ base = "#{request.base_url}#{request.path}"
33
+ query.empty? ? base : "#{base}?#{Rack::Utils.build_nested_query(query)}"
34
+ end
35
+
36
+ # build_nested_query wants String keys and String leaves.
37
+ def stringify_deep(value)
38
+ return value.to_h { |k, v| [k.to_s, stringify_deep(v)] } if value.is_a?(Hash)
39
+
40
+ value.to_s
41
+ end
42
+
43
+ # `links` is { rel => url }; nil urls are skipped and nothing is set when
44
+ # none remain. Appends to an existing Link header, comma-separated.
45
+ def append(response, links)
46
+ entries = links.filter_map { |rel, url| %(<#{url}>; rel="#{rel}") if url }
47
+ return if entries.empty?
48
+
49
+ existing = response.headers["Link"]
50
+ response.set_header("Link", [existing, entries.join(", ")].reject { |part| part.nil? || part.empty? }.join(", "))
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.27.0".freeze
2
+ VERSION = "1.28.1".freeze
3
3
  end
@@ -80,6 +80,7 @@ module ConcernsOnRails
80
80
  autoload :Encryptor, "concerns_on_rails/support/encryptor"
81
81
  autoload :Affix, "concerns_on_rails/support/affix"
82
82
  autoload :BatchOps, "concerns_on_rails/support/batch_ops"
83
+ autoload :LinkHeader, "concerns_on_rails/support/link_header"
83
84
  end
84
85
 
85
86
  # Encryption config + error types (Support::Encryptor requires it itself)
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.27.0
4
+ version: 1.28.1
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-08-30 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -186,6 +186,7 @@ files:
186
186
  - lib/concerns_on_rails/support/error_envelope.rb
187
187
  - lib/concerns_on_rails/support/filter_parameter_registry.rb
188
188
  - lib/concerns_on_rails/support/html_sanitizers.rb
189
+ - lib/concerns_on_rails/support/link_header.rb
189
190
  - lib/concerns_on_rails/support/masker.rb
190
191
  - lib/concerns_on_rails/support/money.rb
191
192
  - lib/concerns_on_rails/support/random_value.rb