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 +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +40 -4
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +24 -1
- data/lib/concerns_on_rails/controllers/paginatable.rb +181 -32
- data/lib/concerns_on_rails/support/link_header.rb +54 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f9aef0d5f95ffc23ef6213d5d95ea872a6146763d488a287a334db8be68b922
|
|
4
|
+
data.tar.gz: b364f4276149a31b1ce1f6efaf170435900f34188eb358035c10020567e8b51d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
58
|
-
# body-based pagination (compose with Respondable's `meta:`). Call
|
|
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
|
-
|
|
63
|
-
|
|
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 =
|
|
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
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
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(
|
|
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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|