concerns_on_rails 1.27.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b06bf25720ff6c5c3a243efdb7b0d575351d50b8a9f33ae4778c1354816075bb
4
- data.tar.gz: 01dc0ff521bee7b5c74c9f30ddee414932441d2ceff84a15af1d9ae8e6d30ccc
3
+ metadata.gz: 5f9aef0d5f95ffc23ef6213d5d95ea872a6146763d488a287a334db8be68b922
4
+ data.tar.gz: b364f4276149a31b1ce1f6efaf170435900f34188eb358035c10020567e8b51d
5
5
  SHA512:
6
- metadata.gz: 37a533852c90cc1876519bd8b52fd07f165bfcfb02efca47bd1ed9d3ff86de94457755d57f9b986d244674a29992cead1b43698de7e3480e92276a0b9c9f1eda
7
- data.tar.gz: 3651f6a175bf1a1e203bbe8353a9fc8b7a2c6457dd19f73e89e31fa108a8e238f36a7004aeffface605800b2838f6bb935694ef823a65dc0be943763bfd31293
6
+ metadata.gz: 025306bb013a418c8051db996ddd0fe6f2bf7ac7f657d22335639b5bcceecff8416e85c66ebde92a357656eeaba90f51a2993fab23232267af105e823cf28934
7
+ data.tar.gz: b0d98d45a2b189b43985e5d5548c3b905a161f2aa0df9022d251a2788b62524360bc002d2981e7e9801a10e4b0f7aa4a7b1d6b0ee5adb4298edeef89a07a8467
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.0 (2026-09-06)
4
+
5
+ Pagination is the theme: the four-PR Paginatable stack (#40, #45, #62, #83)
6
+ from the September enhancement loop. Paginatable now paginates in-memory
7
+ collections, both paginators emit RFC 8288 `Link` headers, results that were
8
+ already paginated upstream can pass `total:`, and the page / per-page parameter
9
+ names (including JSON:API's `page[number]` / `page[size]`) are configurable.
10
+ Controller-only: no new columns, migrations or runtime dependencies. 1303
11
+ examples, 0 failures.
12
+
13
+ ### Added
14
+ - **Controllers::Paginatable**: `paginated` / `pagination_meta` accept in-memory
15
+ collections — an `Array`, `Set`, `Range` or any other non-Hash `Enumerable` —
16
+ not only ActiveRecord relations. The collection is materialized once, the
17
+ total is its size and the current page comes back as an `Array` with the same
18
+ `X-Total-Count` / `X-Page` / `X-Per-Page` / `X-Total-Pages` headers and
19
+ memoized meta. Relations still paginate in SQL. A `Hash`, `nil` or a
20
+ non-collection raises `ArgumentError` naming the class received. (#40)
21
+ - **Controllers::Paginatable / CursorPaginatable**: RFC 8288 `Link` response
22
+ header — `first`/`prev`/`next`/`last` page URLs for Paginatable, `next`
23
+ (+ `prev` in bidirectional mode, `first` once a cursor is in play) for
24
+ CursorPaginatable — rebuilt from the current request with every other query
25
+ param preserved and appended to any existing `Link` header. Opt out with
26
+ `link_header: false` on either macro. Backed by the new shared
27
+ `Support::LinkHeader`. (#45)
28
+ - **Controllers::Paginatable**: `paginated(collection, total:)` /
29
+ `pagination_meta(total:)` for collections that are already one page — an
30
+ external API's or search service's page N plus its total: nothing is sliced
31
+ or counted, and `total` drives the `X-*` headers and the `Link` header.
32
+ `total:` must be a non-negative Integer. (#62)
33
+ - **Controllers::Paginatable**: `paginate_by page_param:` / `per_page_param:`
34
+ (a top-level name or a nested path such as `%i[page number]`) and
35
+ `style: :jsonapi` (`?page[number]=&page[size]=`); explicit `*_param:` options
36
+ win over the style, anything else raises at declaration. Readers dig the path
37
+ through `ScalarParam`, so a scalar where a Hash is expected falls back to the
38
+ default instead of raising. The `Link` header URLs are rebuilt under the
39
+ configured names (nested paths Rack-encoded, sibling keys preserved). (#83)
40
+
41
+ ### Notes
42
+ The `Link` header is **on by default**, so every non-empty paginated response
43
+ grows by one header. Its URLs are built from `request.base_url` + `request.path`,
44
+ i.e. whatever host Rails sees — behind a proxy configure `trusted_proxies` /
45
+ forwarded headers, or pass `link_header: false`. The `X-*` headers are
46
+ unchanged either way, and the existing `?page=&per_page=` behaviour is
47
+ byte-for-byte the same when no `page_param:` / `per_page_param:` / `style:` is
48
+ given.
49
+
50
+ ### Internal
51
+ - New `Support::LinkHeader` (autoloaded): `available?(controller)` — a request
52
+ exposing `base_url` / `path` / `query_parameters` (the dependency-free
53
+ `FakeController` has none, so emission is silently skipped); `url_for(request,
54
+ drop:, **overrides)` — Rack nested-query encoding, nested params survive, Hash
55
+ override values deep-stringified, `nil` / `drop:` removes a key;
56
+ `append(response, rels)` — never clobbers an existing `Link`.
57
+ - `Paginatable.paginate_by` moved into `module ClassMethods` alongside its new
58
+ private helpers (RuboCop scope rule; the Stateable precedent).
59
+ - Development dependencies: simplecov `~> 1.1`, sqlite3 `~> 2.9.6`, rubocop
60
+ `~> 1.89`; GitHub Actions `checkout` v7, `configure-pages` v6, `deploy-pages`
61
+ v5, `upload-pages-artifact` v5. The lockfile now resolves `permittable` 0.2.0
62
+ (the `~> 0.1` runtime pin is unchanged).
63
+
3
64
  ## 1.27.0 (2026-08-29)
4
65
 
5
66
  Scope-name collisions finally have an escape hatch on the eight concerns whose
data/README.md CHANGED
@@ -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,19 @@ 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
+ **Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`, and an RFC 8288 `Link`
1498
+ header with `first` / `prev` / `next` / `last` URLs rebuilt from the current request (other query params
1499
+ preserved; `prev`/`next` only when such a page exists; nothing for an empty collection) — the GitHub
1500
+ convention, so clients follow links instead of computing page numbers. Appended to any `Link` header
1501
+ already set (Deprecatable, CDN hints). `paginate_by link_header: false` turns it off.
1466
1502
 
1467
1503
  ---
1468
1504
 
@@ -1490,7 +1526,7 @@ end
1490
1526
  | `?per_page=` | `25` | Capped at `max_per_page` (default 200; `0` disables the cap) |
1491
1527
  | `?order=` | first preset | With `order_presets:` only — selects a named ordering from the allow-list (unknown names → 400 `invalid_order_preset`) |
1492
1528
 
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`.
1529
+ **Response headers**: `X-Per-Page`, `X-Count` (rows on **this** page — totals are deliberately not computed), `X-Has-More`, `X-Next-Cursor` (only while more pages exist). With `bidirectional: true`: also `X-Has-Prev`, `X-Prev-Cursor`. Plus an RFC 8288 `Link` header: `rel="next"` carries the next-cursor URL, `rel="prev"` the prev-cursor URL (bidirectional), `rel="first"` the current URL with the cursor dropped (once a cursor is in play); `per_page` and the order preset are preserved. `cursor_paginate_by link_header: false` turns it off.
1494
1530
 
1495
1531
  **Notes**
1496
1532
  - The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen **in code** (never from params) and should be `NOT NULL` (a NULL boundary value raises rather than silently dropping rows).
@@ -2086,9 +2122,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2086
2122
 
2087
2123
  ```sh
2088
2124
  bundle install # install dev dependencies
2089
- bundle exec rspec # run the test suite (1,245 examples)
2125
+ bundle exec rspec # run the test suite (1,303 examples)
2090
2126
  gem build concerns_on_rails.gemspec # build the gem
2091
- gem install ./concerns_on_rails-1.27.0.gem # install locally
2127
+ gem install ./concerns_on_rails-1.28.0.gem # install locally
2092
2128
 
2093
2129
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2094
2130
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -2,6 +2,7 @@ require "active_support/concern"
2
2
  require "concerns_on_rails/support/error_envelope"
3
3
  require "concerns_on_rails/support/scalar_param"
4
4
  require "json"
5
+ require "concerns_on_rails/support/link_header"
5
6
  require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
6
7
 
7
8
  module ConcernsOnRails
@@ -141,6 +142,7 @@ module ConcernsOnRails
141
142
  class_attribute :cursor_paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
142
143
  class_attribute :cursor_paginatable_bidirectional, default: false
143
144
  class_attribute :cursor_paginatable_predicate, default: :auto
145
+ class_attribute :cursor_paginatable_link_header, default: true
144
146
 
145
147
  # Real controllers (anything with ActiveSupport::Rescuable) get the 400
146
148
  # handlers automatically; bare objects let the errors propagate.
@@ -161,7 +163,7 @@ module ConcernsOnRails
161
163
  # max_per_page: 0 (or negative) disables the per_page cap.
162
164
  def cursor_paginate_by(order: nil, order_presets: nil, default_preset: nil, order_param: :order,
163
165
  per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE,
164
- bidirectional: false, predicate: :auto)
166
+ bidirectional: false, predicate: :auto, link_header: true)
165
167
  self.cursor_paginatable_order = order && CursorPaginatable.normalize_order!(order)
166
168
  self.cursor_paginatable_order_presets = order_presets && CursorPaginatable.normalize_presets!(order_presets)
167
169
  self.cursor_paginatable_default_preset =
@@ -171,6 +173,7 @@ module ConcernsOnRails
171
173
  self.cursor_paginatable_max_per_page = max_per_page.to_i
172
174
  self.cursor_paginatable_bidirectional = bidirectional ? true : false
173
175
  self.cursor_paginatable_predicate = CursorPaginatable.validate_predicate!(predicate)
176
+ self.cursor_paginatable_link_header = link_header ? true : false
174
177
  end
175
178
  end
176
179
 
@@ -506,11 +509,31 @@ module ConcernsOnRails
506
509
  response.set_header("X-Count", meta[:count].to_s)
507
510
  response.set_header("X-Has-More", meta[:has_more].to_s)
508
511
  response.set_header("X-Next-Cursor", meta[:next_cursor]) if meta[:next_cursor]
512
+ apply_cursor_pagination_links(meta)
509
513
  return unless meta.key?(:has_prev)
510
514
 
511
515
  response.set_header("X-Has-Prev", meta[:has_prev].to_s)
512
516
  response.set_header("X-Prev-Cursor", meta[:prev_cursor]) if meta[:prev_cursor]
513
517
  end
518
+
519
+ # RFC 8288 Link: rel="next" carries the X-Next-Cursor token, rel="prev"
520
+ # the X-Prev-Cursor one (bidirectional mode), rel="first" is the current
521
+ # URL with the cursor dropped — emitted once a cursor is in play. Other
522
+ # query params (per_page, the order preset) are preserved. Skipped when
523
+ # disabled or without a real request.
524
+ def apply_cursor_pagination_links(meta)
525
+ return unless self.class.cursor_paginatable_link_header
526
+ return unless ConcernsOnRails::Support::LinkHeader.available?(self)
527
+
528
+ cursor_url = ->(token) { ConcernsOnRails::Support::LinkHeader.url_for(request, cursor: token) }
529
+ raw_cursor = params[:cursor]
530
+ ConcernsOnRails::Support::LinkHeader.append(
531
+ response,
532
+ first: ConcernsOnRails::Support::ScalarParam.scalar?(raw_cursor) && !raw_cursor.to_s.empty? ? cursor_url.call(nil) : nil,
533
+ prev: meta[:prev_cursor] && cursor_url.call(meta[:prev_cursor]),
534
+ next: meta[:next_cursor] && cursor_url.call(meta[:next_cursor])
535
+ )
536
+ end
514
537
  end
515
538
  end
516
539
  end
@@ -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,61 +15,124 @@ 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
+ # Where page / per_page are read from — a path of param names (`["page"]`,
45
+ # or `["page", "number"]` for JSON:API's page[number]).
46
+ class_attribute :paginatable_page_param, default: %w[page].freeze
47
+ class_attribute :paginatable_per_page_param, default: %w[per_page].freeze
26
48
  end
27
49
 
28
- class_methods do
29
- # Configure the default page size and the hard cap on per_page.
50
+ # A real module (not `class_methods do`) so the macro and its private
51
+ # helpers share one `private` without tripping RuboCop's scope analysis.
52
+ module ClassMethods
53
+ # Configure the default page size, the hard cap on per_page, and whether
54
+ # the RFC 8288 Link header is emitted.
30
55
  # 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)
56
+ # paginate_by per_page: 50, max_per_page: 500, link_header: false
57
+ 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)
33
59
  self.paginatable_per_page = per_page.to_i
34
60
  self.paginatable_max_per_page = max_per_page.to_i
61
+ self.paginatable_link_header = link_header ? true : false
62
+ defaults = paginatable_style_params!(style)
63
+ self.paginatable_page_param = paginatable_param_path!(:page_param, page_param || defaults[0])
64
+ self.paginatable_per_page_param = paginatable_param_path!(:per_page_param, per_page_param || defaults[1])
65
+ end
66
+
67
+ private
68
+
69
+ # :flat → page / per_page; :jsonapi → page[number] / page[size].
70
+ def paginatable_style_params!(style)
71
+ case style.to_sym
72
+ when :flat then [%w[page], %w[per_page]]
73
+ when :jsonapi then [%w[page number], %w[page size]]
74
+ else raise ArgumentError, "#{LABEL}: style: must be :flat or :jsonapi (got #{style.inspect})"
75
+ end
76
+ end
77
+
78
+ # A name or a non-empty path of names, normalized to Strings.
79
+ def paginatable_param_path!(option, value)
80
+ path = Array(value)
81
+ valid = path.any? && path.all? { |segment| (segment.is_a?(Symbol) || segment.is_a?(String)) && !segment.to_s.empty? }
82
+ raise ArgumentError, "#{LABEL}: #{option}: must be a param name or a path of names (got #{value.inspect})" unless valid
83
+
84
+ path.map(&:to_s).freeze
35
85
  end
36
86
  end
37
87
 
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)
88
+ # Apply pagination to a relation or an in-memory collection and set the
89
+ # standard response headers. A relation comes back as a relation with
90
+ # LIMIT/OFFSET applied (still lazy); an Enumerable comes back as the
91
+ # current page's Array slice (`[]` past the last page). The metadata is
92
+ # memoized so a follow-up `pagination_meta` (no argument) reuses it.
93
+ # Safe on empty collections.
94
+ #
95
+ # `total:` says the collection IS the current page already — an external
96
+ # API or search service returned page N of a result set it counted for
97
+ # you. Nothing is sliced, limited or counted: the records come back
98
+ # untouched and `total` drives X-Total-Count, X-Total-Pages and the Link
99
+ # header. Ask the upstream for the same page/per_page you read here.
100
+ def paginated(collection, total: nil)
42
101
  @paginatable_meta = nil
102
+ source = paginatable_source(collection)
103
+ pre_paginated = !total.nil?
43
104
  page = pagination_page
44
105
  per_page = pagination_per_page
45
106
  offset = (page - 1) * per_page
46
107
 
47
- total = paginatable_total(relation)
108
+ total = pre_paginated ? paginatable_validate_total!(total) : paginatable_total(source)
48
109
  total_pages = per_page.positive? ? (total.to_f / per_page).ceil : 0
49
110
 
50
- records = relation.limit(per_page).offset(offset)
111
+ records =
112
+ if pre_paginated
113
+ source # the caller already fetched exactly this page: an Array stays an Array, a relation is not limited
114
+ elsif source.is_a?(Array)
115
+ source[offset, per_page] || []
116
+ else
117
+ source.limit(per_page).offset(offset)
118
+ end
51
119
 
52
120
  @paginatable_meta = { total: total, page: page, per_page: per_page, total_pages: total_pages }
53
121
  set_pagination_headers(**@paginatable_meta)
122
+ set_pagination_links(page: page, total_pages: total_pages)
54
123
  records
55
124
  end
56
125
 
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
126
+ # Pagination metadata WITHOUT applying limit/offset (or slicing) — handy
127
+ # for body-based pagination (compose with Respondable's `meta:`). Call
128
+ # with no argument after `paginated` to reuse its memoized meta — the
60
129
  # 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
130
+ # twice per request. Pass a relation or collection to compute fresh.
131
+ # With `total:` the COUNT is skipped (and the collection may be omitted).
132
+ def pagination_meta(collection = nil, total: nil)
133
+ return @paginatable_meta if collection.nil? && total.nil? && @paginatable_meta
70
134
 
71
- total = paginatable_total(relation)
135
+ total = paginatable_meta_total(collection, total)
72
136
  per_page = pagination_per_page
73
137
  {
74
138
  total: total,
@@ -80,29 +144,90 @@ module ConcernsOnRails
80
144
 
81
145
  private
82
146
 
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)
147
+ # Relations anything answering `limit` and `offset`: an
148
+ # ActiveRecord::Relation, an association CollectionProxy, a model class
149
+ # pass through untouched so they keep paginating in SQL. Any other
150
+ # non-Hash Enumerable is materialized ONCE into an Array, so an
151
+ # Enumerator is not consumed twice (once to count, once to slice). A Hash
152
+ # is rejected rather than silently paginated as [key, value] pairs.
153
+ def paginatable_source(collection)
154
+ return collection if collection.respond_to?(:limit) && collection.respond_to?(:offset)
155
+ return collection.to_a if collection.is_a?(Enumerable) && !collection.is_a?(Hash)
156
+
157
+ hint = collection.is_a?(Hash) ? " — call .to_a to paginate a Hash as [key, value] pairs" : ""
158
+ raise ArgumentError,
159
+ "#{LABEL}: expected an ActiveRecord relation or an Enumerable (Array, Set, Range, ...), " \
160
+ "got #{collection.class}#{hint}"
161
+ end
162
+
163
+ # `total:` wins (validated); otherwise COUNT the collection; neither
164
+ # given and nothing memoized is a caller error.
165
+ def paginatable_meta_total(collection, total)
166
+ return paginatable_validate_total!(total) unless total.nil?
167
+ return paginatable_total(paginatable_source(collection)) unless collection.nil?
168
+
169
+ raise ArgumentError,
170
+ "#{LABEL}: pagination_meta needs a relation or collection " \
171
+ "(no prior paginated call in this request to reuse)"
172
+ end
173
+
174
+ def paginatable_validate_total!(total)
175
+ return total if total.is_a?(Integer) && total >= 0
176
+
177
+ raise ArgumentError, "#{LABEL}: total: must be a non-negative Integer (got #{total.inspect})"
178
+ end
179
+
180
+ # Arrays already know their size. Relations COUNT with the clauses that
181
+ # break or skew it stripped: order/limit/offset are irrelevant, a custom
182
+ # SELECT list would turn into the invalid COUNT(a, b), and count(:all)
183
+ # keeps DISTINCT semantics. A grouped relation counts as a Hash
184
+ # (group => count); the meaningful total is the number of groups.
185
+ def paginatable_total(source)
186
+ return source.size if source.is_a?(Array)
187
+
188
+ counted = source.except(:order, :limit, :offset, :select).count(:all)
90
189
  counted.is_a?(Hash) ? counted.length : counted
91
190
  end
92
191
 
93
192
  # Both readers route through ScalarParam: `?page[]=1` / `?page[x]=1`
94
193
  # arrive as Array/Parameters, and calling .to_i on those was a 500.
95
194
  def pagination_page
96
- [ConcernsOnRails::Support::ScalarParam.to_i(params[:page], default: 0), 1].max
195
+ [ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0), 1].max
97
196
  end
98
197
 
99
198
  def pagination_per_page
100
- requested = ConcernsOnRails::Support::ScalarParam.to_i(params[:per_page], default: 0)
199
+ requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_per_page_param), default: 0)
101
200
  requested = self.class.paginatable_per_page if requested < 1
102
201
  cap = self.class.paginatable_max_per_page
103
202
  cap.positive? ? [requested, cap].min : requested
104
203
  end
105
204
 
205
+ # Dig the configured path out of params: `["page"]` → params[:page];
206
+ # `["page", "number"]` → params[:page][:number]. A scalar where a Hash
207
+ # is expected yields nil (→ the default), like any other garbage.
208
+ def pagination_param(path)
209
+ path.reduce(params) do |node, key|
210
+ break nil unless node.respond_to?(:[]) && !node.is_a?(String) && !node.is_a?(Array)
211
+
212
+ node[key]
213
+ end
214
+ end
215
+
216
+ # The `overrides` for LinkHeader.url_for that set the page number under the
217
+ # configured name — replacing the whole nested Hash for a path so the
218
+ # other keys in it (page[size]) survive.
219
+ def pagination_page_override(number)
220
+ path = self.class.paginatable_page_param
221
+ return { path.first.to_sym => number } if path.size == 1
222
+
223
+ nested = request.query_parameters.to_h.transform_keys(&:to_s)[path.first]
224
+ nested = nested.is_a?(Hash) ? nested.deep_dup : {}
225
+ node = nested
226
+ path[1...-1].each { |key| node = (node[key] = node[key].is_a?(Hash) ? node[key] : {}) }
227
+ node[path.last] = number
228
+ { path.first.to_sym => nested }
229
+ end
230
+
106
231
  def set_pagination_headers(total:, page:, per_page:, total_pages:)
107
232
  return unless respond_to?(:response) && response
108
233
 
@@ -111,6 +236,30 @@ module ConcernsOnRails
111
236
  response.set_header("X-Per-Page", per_page.to_s)
112
237
  response.set_header("X-Total-Pages", total_pages.to_s)
113
238
  end
239
+
240
+ # Link: <…?page=1>; rel="first", <…?page=1>; rel="prev", <…?page=3>;
241
+ # rel="next", <…?page=5>; rel="last". prev/next only when such a page
242
+ # exists; past the end, prev points at the last page. Nothing is emitted
243
+ # for an empty collection, when disabled, or without a real request.
244
+ def set_pagination_links(page:, total_pages:)
245
+ return unless pagination_links_applicable?(total_pages)
246
+
247
+ page_url = ->(number) { ConcernsOnRails::Support::LinkHeader.url_for(request, **pagination_page_override(number)) }
248
+ ConcernsOnRails::Support::LinkHeader.append(
249
+ response,
250
+ first: page_url.call(1),
251
+ prev: page > 1 ? page_url.call([page - 1, total_pages].min) : nil,
252
+ next: page < total_pages ? page_url.call(page + 1) : nil,
253
+ last: page_url.call(total_pages)
254
+ )
255
+ end
256
+
257
+ def pagination_links_applicable?(total_pages)
258
+ return false unless self.class.paginatable_link_header && total_pages.positive?
259
+ return false unless respond_to?(:response) && response
260
+
261
+ ConcernsOnRails::Support::LinkHeader.available?(self)
262
+ end
114
263
  end
115
264
  end
116
265
  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.0".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.0
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-06 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