standard_pagination 0.1.1 → 0.2.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 +12 -0
- data/README.md +40 -5
- data/lib/standard_pagination/config.rb +8 -3
- data/lib/standard_pagination/controller.rb +65 -3
- data/lib/standard_pagination/version.rb +1 -1
- data/lib/standard_pagination.rb +7 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 488e76612676ede6f2beabac3c2bcd9c64e9568de2e69967d11fda8e704cb2ea
|
|
4
|
+
data.tar.gz: f06fdab7e881ecd290e20287f2f073614ad92b895e035881be0f147f98e8ac64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c6b369a714822c0d50025ad79f7f9919ba2e2fa735172651b11fbc6edb491321b118abd00135ddbb0eb43b418d384241df6615c2815e077bbf1e696baee969d
|
|
7
|
+
data.tar.gz: f33f5b5a13a41e8c7169996e355e20717c24a32ad1518f7eb49bbeddefe527f5888d2e963d5d93530cabc9db4b1fe9629b2692559f77d50fa4165df718761211
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Controller#paginate_cursor` and `#cursor_data`: keyset (cursor) pagination for feeds, with one envelope, `{ data, next_cursor, has_more }`. The client sends `next_cursor` back as `?cursor=` (`config.cursor_key`). The primary key is appended so the order is total, and a cursor the server didn't issue is a 400 for JSON, or a redirect without it for HTML. This is the estate convention for new feeds. sidekick-web, jumpdrive and the rest had each hand-rolled their own (`has_more`/`before`/`next_page_token`, `next_cursor`, `since`).
|
|
14
|
+
- `config.client_limit_formats = :non_html` lets every request whose format isn't HTML choose the page size.
|
|
15
|
+
- `pagination_client_limit`, a class attribute that sets the `client_limit:` default for a controller and its subclasses. Set it on an API base controller: a client that sends no JSON `Accept` header is treated as HTML by Rails, so neither the JSON default nor `:non_html` would let it choose a page size. sidekick-web's sandbox endpoint passed `client_limit: true` per call for this reason.
|
|
16
|
+
|
|
17
|
+
## [0.1.2] - 2026-10-26
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `config.limit_key` accepts an Array of aliases, e.g. `%w[limit per_page]`; the first one the request sends wins. For APIs whose clients use both (luminality-web). The out-of-range redirect drops all of them. (Meant for 0.1.1, but that PR auto-merged before this commit landed.)
|
|
21
|
+
|
|
10
22
|
## [0.1.1] - 2026-10-25
|
|
11
23
|
|
|
12
24
|
### Fixed
|
data/README.md
CHANGED
|
@@ -45,8 +45,9 @@ Delete `max_limit` (or `client_limit`) from `config/initializers/pagy.rb`. Leave
|
|
|
45
45
|
StandardPagination.configure do |c|
|
|
46
46
|
c.default_limit = 20 # rows when a call site passes no limit:
|
|
47
47
|
c.max_limit = 100 # cap on a client-chosen page size
|
|
48
|
-
c.limit_key = "limit" # param a client sets it with
|
|
49
|
-
c.client_limit_formats = %i[json]
|
|
48
|
+
c.limit_key = "limit" # param a client sets it with; an Array accepts aliases, e.g. %w[limit per_page]
|
|
49
|
+
c.client_limit_formats = %i[json] # or :non_html for every non-HTML request
|
|
50
|
+
c.cursor_key = "cursor" # param paginate_cursor reads the cursor from
|
|
50
51
|
c.tiebreak = true
|
|
51
52
|
c.raise_range_error = true
|
|
52
53
|
c.strict_options = nil # nil = raise in dev/test, log elsewhere
|
|
@@ -69,7 +70,18 @@ render inertia: "factory/devices/index", props: {
|
|
|
69
70
|
`paginate` options:
|
|
70
71
|
|
|
71
72
|
- `limit:` is the page size. It defaults to `config.default_limit`.
|
|
72
|
-
- `client_limit:` lets the request set the page size through `config.limit_key`. Pass an Integer cap, `true` for `config.max_limit`, or `false` to turn it off.
|
|
73
|
+
- `client_limit:` lets the request set the page size through `config.limit_key`. Pass an Integer cap, `true` for `config.max_limit`, or `false` to turn it off. A request value that is junk, zero or negative falls back to `limit:`. When omitted it falls back to:
|
|
74
|
+
1. the controller's `pagination_client_limit`, if set;
|
|
75
|
+
2. otherwise `config.client_limit_formats`: on for JSON and off for HTML by default.
|
|
76
|
+
|
|
77
|
+
An API base controller whose clients may send no `Accept: application/json` header (Rails treats those requests as HTML) should opt in once, instead of per call:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
class Api::BaseController < ActionController::API
|
|
81
|
+
include StandardPagination::Controller
|
|
82
|
+
self.pagination_client_limit = true # or an Integer cap
|
|
83
|
+
end
|
|
84
|
+
```
|
|
73
85
|
- `tiebreak:` defaults to `config.tiebreak`.
|
|
74
86
|
- `paginator:` defaults to `:offset`. `:countless` skips the `COUNT(*)` on big append-only tables. Any other pagy option, such as `page_key:` or `count:`, passes through.
|
|
75
87
|
|
|
@@ -111,6 +123,29 @@ An unordered relation gets `ORDER BY id ASC`. The relation is left alone when th
|
|
|
111
123
|
|
|
112
124
|
Outside a paginator (a query object, or an engine scope a host will page through), use `Model.stable_order(...)`, for example `AuditLog.where(...).stable_order(occurred_at: :desc)`.
|
|
113
125
|
|
|
126
|
+
## Cursor pagination
|
|
127
|
+
|
|
128
|
+
For feeds and append-heavy APIs, `paginate_cursor` uses pagy's keyset paginator: no `COUNT`, no `OFFSET`, and rows inserted while a client pages don't shift or repeat what it sees.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
pagy, events = paginate_cursor(Event.where(device:).order(occurred_at: :desc), limit: 50)
|
|
132
|
+
|
|
133
|
+
render json: { data: events.map { EventSerializer.new(_1) }, **cursor_data(pagy) }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This is the one envelope for cursor-paged responses:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{ "data": [...], "next_cursor": "eyJ...", "has_more": true }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- The client sends `next_cursor` back as `?cursor=` (`config.cursor_key`). `next_cursor` is `null` and `has_more` is `false` on the last page.
|
|
143
|
+
- Order the set with hash or symbol orders (`order(occurred_at: :desc)`), not SQL strings: pagy builds the cursor from those columns. The primary key is appended so the order is total.
|
|
144
|
+
- `limit:`, `client_limit:` and `tiebreak:` work as in `paginate`.
|
|
145
|
+
- A cursor the server didn't issue raises `Pagy::OptionError`: `rescue_pagination_errors` answers 400 for JSON, and for HTML redirects without the cursor.
|
|
146
|
+
|
|
147
|
+
Use it for new feeds, and when an existing feed is reworked. Feeds that mobile clients already parse keep their current shape until their clients move.
|
|
148
|
+
|
|
114
149
|
## MCP tools
|
|
115
150
|
|
|
116
151
|
```ruby
|
|
@@ -122,10 +157,10 @@ end
|
|
|
122
157
|
|
|
123
158
|
It returns `[records, { page:, per_page:, total_count:, total_pages: }]`, the shape MCP clients already parse. A `GROUP BY` relation counts its groups, and a page past the end returns no records.
|
|
124
159
|
|
|
125
|
-
## Not in
|
|
160
|
+
## Not in the gem
|
|
126
161
|
|
|
127
|
-
- **Keyset or cursor pagination for feeds and APIs.** Today these are hand-rolled in sidekick-web's companion queries and in jumpdrive. 0.2 is meant to settle on a single `next_cursor` / `has_more` envelope.
|
|
128
162
|
- **A shared React `<Pagination>` component.** It belongs in a frontend package, not a gem.
|
|
163
|
+
- **Cursor pagination for MCP list tools.** `Mcp.paginate` keeps the offset shape MCP clients already parse.
|
|
129
164
|
|
|
130
165
|
## Development
|
|
131
166
|
|
|
@@ -4,11 +4,15 @@ module StandardPagination
|
|
|
4
4
|
attr_accessor :default_limit
|
|
5
5
|
# Largest page size a client may request when client limits are on.
|
|
6
6
|
attr_accessor :max_limit
|
|
7
|
-
# Request param a client sets the page size with (when allowed).
|
|
7
|
+
# Request param a client sets the page size with (when allowed). An Array
|
|
8
|
+
# accepts aliases; the first one the request sends wins.
|
|
8
9
|
attr_accessor :limit_key
|
|
9
|
-
# Formats for which `paginate` honours the client's page size by default
|
|
10
|
-
#
|
|
10
|
+
# Formats for which `paginate` honours the client's page size by default:
|
|
11
|
+
# an Array of format symbols, or :non_html for every request whose format
|
|
12
|
+
# isn't HTML. HTML pages keep the size the call site chose.
|
|
11
13
|
attr_accessor :client_limit_formats
|
|
14
|
+
# Request param `paginate_cursor` reads the cursor from.
|
|
15
|
+
attr_accessor :cursor_key
|
|
12
16
|
# Pagy raises Pagy::RangeError for a page past the last one, which
|
|
13
17
|
# Controller.rescue_pagination_errors turns into a redirect, 404 or 400.
|
|
14
18
|
# With it off, pagy serves an empty page instead.
|
|
@@ -24,6 +28,7 @@ module StandardPagination
|
|
|
24
28
|
@max_limit = 100
|
|
25
29
|
@limit_key = "limit"
|
|
26
30
|
@client_limit_formats = %i[json]
|
|
31
|
+
@cursor_key = "cursor"
|
|
27
32
|
@raise_range_error = true
|
|
28
33
|
@tiebreak = true
|
|
29
34
|
@strict_options = nil
|
|
@@ -19,6 +19,13 @@ module StandardPagination
|
|
|
19
19
|
include Pagy::Method
|
|
20
20
|
|
|
21
21
|
class_attribute :_pagination_error_handling, instance_accessor: false, default: nil
|
|
22
|
+
# The `client_limit:` default for every `paginate`/`paginate_cursor` in
|
|
23
|
+
# this controller and its subclasses (true, an Integer cap, or false).
|
|
24
|
+
# nil falls back to config.client_limit_formats. Set it on an API base
|
|
25
|
+
# controller, whose clients may send no JSON Accept header at all:
|
|
26
|
+
#
|
|
27
|
+
# self.pagination_client_limit = true
|
|
28
|
+
class_attribute :pagination_client_limit, instance_accessor: false, default: nil
|
|
22
29
|
end
|
|
23
30
|
|
|
24
31
|
class_methods do
|
|
@@ -92,17 +99,67 @@ module StandardPagination
|
|
|
92
99
|
StandardPagination.data(pagy)
|
|
93
100
|
end
|
|
94
101
|
|
|
102
|
+
# Cursor (keyset) pagination for feeds: no COUNT, no OFFSET, and rows
|
|
103
|
+
# inserted while a client pages don't shift what it sees. Returns
|
|
104
|
+
# `[pagy, records]`; send `cursor_data(pagy)` with the records.
|
|
105
|
+
#
|
|
106
|
+
# The set must be ordered with hash/symbol orders (`order(created_at:
|
|
107
|
+
# :desc)`), which pagy turns into the cursor's columns; the primary key
|
|
108
|
+
# is appended as a tie-breaker so the order is total. The client sends
|
|
109
|
+
# back `next_cursor` as config.cursor_key (`?cursor=`). `limit:`,
|
|
110
|
+
# `client_limit:` and `tiebreak:` work as in `paginate`.
|
|
111
|
+
#
|
|
112
|
+
# A cursor this didn't issue raises Pagy::OptionError, which
|
|
113
|
+
# rescue_pagination_errors answers with a 400 (or, for HTML, a redirect
|
|
114
|
+
# without it).
|
|
115
|
+
def paginate_cursor(set, limit: nil, client_limit: nil, tiebreak: nil, **options)
|
|
116
|
+
config = StandardPagination.config
|
|
117
|
+
tiebreak = config.tiebreak if tiebreak.nil?
|
|
118
|
+
set = StableOrder.apply(set) if tiebreak
|
|
119
|
+
|
|
120
|
+
cursor = params[config.cursor_key]
|
|
121
|
+
if cursor.present? && !(cursor.is_a?(String) && Pagy::Keyset.decode(cursor).is_a?(Array))
|
|
122
|
+
raise Pagy::OptionError.new(nil, config.cursor_key.to_sym, "to be a next_cursor value", cursor)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
pagy(
|
|
126
|
+
:keyset,
|
|
127
|
+
set,
|
|
128
|
+
**options,
|
|
129
|
+
page_key: config.cursor_key.to_s,
|
|
130
|
+
limit: resolve_pagination_limit(limit || config.default_limit, client_limit),
|
|
131
|
+
max_limit: nil,
|
|
132
|
+
client_limit: nil,
|
|
133
|
+
client_max_limit: nil
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The cursor envelope's metadata: `{ next_cursor:, has_more: }`.
|
|
138
|
+
# `next_cursor` is nil on the last page.
|
|
139
|
+
#
|
|
140
|
+
# render json: { data: ..., **cursor_data(pagy) }
|
|
141
|
+
def cursor_data(pagy)
|
|
142
|
+
StandardPagination.cursor_data(pagy)
|
|
143
|
+
end
|
|
144
|
+
|
|
95
145
|
def resolve_pagination_limit(limit, client_limit)
|
|
96
146
|
cap = pagination_client_limit_cap(client_limit)
|
|
97
147
|
return limit unless cap
|
|
98
148
|
|
|
99
|
-
|
|
149
|
+
# The first configured key the request sends wins.
|
|
150
|
+
key = pagination_limit_keys.find { |candidate| params[candidate].present? }
|
|
151
|
+
requested = key ? params[key].to_s : ""
|
|
100
152
|
return limit unless requested.match?(/\A\d+\z/) && requested.to_i.positive?
|
|
101
153
|
|
|
102
154
|
[ requested.to_i, cap ].min
|
|
103
155
|
end
|
|
104
156
|
|
|
157
|
+
def pagination_limit_keys
|
|
158
|
+
Array(StandardPagination.config.limit_key).map(&:to_s)
|
|
159
|
+
end
|
|
160
|
+
|
|
105
161
|
def pagination_client_limit_cap(client_limit)
|
|
162
|
+
client_limit = self.class.pagination_client_limit if client_limit.nil?
|
|
106
163
|
client_limit = pagination_client_limit_format? if client_limit.nil?
|
|
107
164
|
|
|
108
165
|
case client_limit
|
|
@@ -113,7 +170,9 @@ module StandardPagination
|
|
|
113
170
|
|
|
114
171
|
def pagination_client_limit_format?
|
|
115
172
|
formats = StandardPagination.config.client_limit_formats
|
|
116
|
-
|
|
173
|
+
return !request.format&.html? if formats == :non_html
|
|
174
|
+
|
|
175
|
+
Array(formats).any? { |format| request.format.public_send(:"#{format}?") }
|
|
117
176
|
end
|
|
118
177
|
|
|
119
178
|
def handle_pagination_error(exception)
|
|
@@ -144,7 +203,10 @@ module StandardPagination
|
|
|
144
203
|
if last_page
|
|
145
204
|
query[page_key] = last_page
|
|
146
205
|
else
|
|
147
|
-
query = query.except(
|
|
206
|
+
query = query.except(
|
|
207
|
+
(options[:limit_key] || Pagy::DEFAULT[:limit_key]).to_s, *pagination_limit_keys,
|
|
208
|
+
StandardPagination.config.cursor_key.to_s
|
|
209
|
+
)
|
|
148
210
|
# Nothing left to drop: the bad option came from the call site or a
|
|
149
211
|
# param this gem doesn't know, so redirecting would only repeat it.
|
|
150
212
|
return head(:bad_request) if query == original
|
data/lib/standard_pagination.rb
CHANGED
|
@@ -38,6 +38,13 @@ module StandardPagination
|
|
|
38
38
|
pagy.data_hash(data_keys: DATA_KEYS)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# The one cursor envelope for feeds, alongside the records as `data`:
|
|
42
|
+
# `{ next_cursor: "<opaque>" | nil, has_more: true | false }`.
|
|
43
|
+
def cursor_data(pagy)
|
|
44
|
+
next_cursor = pagy.next
|
|
45
|
+
{ next_cursor:, has_more: !next_cursor.nil? }
|
|
46
|
+
end
|
|
47
|
+
|
|
41
48
|
# Global pagy client-limit options left over from before this gem. They
|
|
42
49
|
# still apply to any bare `pagy(...)` call that bypasses
|
|
43
50
|
# Controller#paginate, letting clients pick the page size.
|