standard_pagination 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +22 -0
- data/README.md +136 -0
- data/Rakefile +6 -0
- data/lib/standard_pagination/config.rb +32 -0
- data/lib/standard_pagination/controller.rb +164 -0
- data/lib/standard_pagination/mcp.rb +40 -0
- data/lib/standard_pagination/model.rb +21 -0
- data/lib/standard_pagination/railtie.rb +16 -0
- data/lib/standard_pagination/stable_order.rb +73 -0
- data/lib/standard_pagination/version.rb +3 -0
- data/lib/standard_pagination.rb +63 -0
- metadata +162 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d60e0bc850fa3f5f3efdfc07c355de37b9c4d569d86f05ddd47962c20e4a1636
|
|
4
|
+
data.tar.gz: dd47c304ed63c9f5a6df5f7c69a07eff5ef32fe495b372fe1cc0b1b072af52e7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f07018870793cd23b22a8043c8e0aa34977dbf46547355922e90855135ee36fa6fb60cccc452f4bebff45ca4667cdd9456e4a176f5f922bde78dfc977dbc407e
|
|
7
|
+
data.tar.gz: 5a0762267cadaf3fad98ad90b2dd7cef1dfee4862bc66ee8d4b1dcf0c1e5966e8384afebe1063c50f434c1b0d073add3b47d39e1c131096cf170a5fa9d468c5e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `StandardPagination::Controller`, a concern that adds:
|
|
14
|
+
- `paginate`, which wraps `pagy`. HTML pages ignore a client-chosen page size, JSON lets clients choose one up to `config.max_limit`, and the primary key is appended as a tie-breaker. It also neutralises any `max_limit`/`client_limit` left in `Pagy::OPTIONS` for that call.
|
|
15
|
+
- `pagination_data`, which returns the standard `data_hash` keys.
|
|
16
|
+
- `rescue_pagination_errors(html:, notice:)`: a 400 for JSON; for HTML a redirect that keeps filters (and answers 400 rather than redirect to the same failing URL), a 404, or a custom method. Declaring it again replaces the earlier handler.
|
|
17
|
+
- `StandardPagination::Mcp.paginate`, the MCP list helper, which keeps the `page`/`per_page`/`total_count`/`total_pages` shape. A non-positive or junk `per_page` now falls back to the default instead of becoming 1.
|
|
18
|
+
- `StandardPagination::StableOrder` and `StandardPagination::Model#stable_order`.
|
|
19
|
+
- A Railtie boot check for leftover client-limit options in `Pagy::OPTIONS`.
|
|
20
|
+
- Tested against pagy 43.6 and 43.8.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rarebit One
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# StandardPagination
|
|
2
|
+
|
|
3
|
+
Shared pagination conventions for the estate's Rails apps, built on [pagy](https://github.com/ddnexus/pagy) 43.
|
|
4
|
+
|
|
5
|
+
Before this gem, sidekick-web, nutripod-web, fundbright-web and luminality-web each kept their own copy of the same helpers, and shared the same bugs:
|
|
6
|
+
|
|
7
|
+
- **Clients could set the page size on every list.** Each app's `Pagy::OPTIONS[:max_limit] = 100` became pagy 43.7's `client_limit`, which lets a client pick any page size up to 100 with `?limit=`, even where the call site fixed one.
|
|
8
|
+
- **Rows repeated or went missing between pages.** Lists sorted only on a timestamp, so equal rows could land in a different order on the next page's query.
|
|
9
|
+
- **Errors returned 500 or looped.** JSON APIs had no pagy rescue, and an HTML redirect could lose filters or loop.
|
|
10
|
+
|
|
11
|
+
The gem provides one copy of each helper, and these fixes:
|
|
12
|
+
|
|
13
|
+
| Piece | What it does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `StandardPagination::Controller` | `paginate` and `pagination_data`. HTML pages keep the page size the call site sets, and JSON clients may choose one up to a cap. It adds an id tie-breaker and lets you opt in to `rescue_pagination_errors`. |
|
|
16
|
+
| `StandardPagination::Mcp.paginate` | The MCP list-tool helper every app's `ApplicationTool` carried a copy of. It keeps the same response shape. |
|
|
17
|
+
| `StandardPagination::StableOrder` / `Model#stable_order` | Appends the primary key to an ORDER BY when that is safe. |
|
|
18
|
+
| Railtie | At boot, flags a leftover `max_limit` or `client_limit` in `Pagy::OPTIONS`. It raises in development and test, and logs elsewhere. |
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "standard_pagination", "~> 0.1"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# app/controllers/application_controller.rb
|
|
28
|
+
class ApplicationController < ActionController::Base
|
|
29
|
+
include StandardPagination::Controller # includes Pagy::Method
|
|
30
|
+
|
|
31
|
+
rescue_from StandardError, with: :render_error # catch-alls first...
|
|
32
|
+
rescue_pagination_errors html: :redirect # ...then this, so it wins
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# app/models/application_record.rb
|
|
36
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
37
|
+
include StandardPagination::Model
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Delete `max_limit` (or `client_limit`) from `config/initializers/pagy.rb`. Leave `raise_range_error` out as well: `paginate` passes `config.raise_range_error`, which defaults to `true`.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# config/initializers/standard_pagination.rb (optional; these are the defaults)
|
|
45
|
+
StandardPagination.configure do |c|
|
|
46
|
+
c.default_limit = 20 # rows when a call site passes no limit:
|
|
47
|
+
c.max_limit = 100 # cap on a client-chosen page size
|
|
48
|
+
c.limit_key = "limit" # param a client sets it with ("per_page" in luminality)
|
|
49
|
+
c.client_limit_formats = %i[json]
|
|
50
|
+
c.tiebreak = true
|
|
51
|
+
c.raise_range_error = true
|
|
52
|
+
c.strict_options = nil # nil = raise in dev/test, log elsewhere
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Controllers
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
pagy, devices = paginate(Device.where(zone:).order(created_at: :desc), limit: 25)
|
|
60
|
+
|
|
61
|
+
render inertia: "factory/devices/index", props: {
|
|
62
|
+
devices: devices.map { DeviceSerializer.new(_1) },
|
|
63
|
+
pagination: pagination_data(pagy)
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`pagination_data` returns `{ page, pages, count, limit, from, to, previous, next }`. `previous` and `next` are left out on the first and last page, as pagy's `data_hash` always did. It is the shape every app's `PaginationMetadata` TypeScript type already describes.
|
|
68
|
+
|
|
69
|
+
`paginate` options:
|
|
70
|
+
|
|
71
|
+
- `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. It defaults to on for JSON and off for HTML. A request value that is junk, zero or negative falls back to `limit:`.
|
|
73
|
+
- `tiebreak:` defaults to `config.tiebreak`.
|
|
74
|
+
- `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
|
+
|
|
76
|
+
For several tables on one page, give each its own `page_key:`:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
pagy_new, new_apps = paginate(new_scope, page_key: "new_page")
|
|
80
|
+
pagy_done, done_apps = paginate(done_scope, page_key: "done_page")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Errors
|
|
84
|
+
|
|
85
|
+
`rescue_pagination_errors` handles `Pagy::OptionError`, which includes its subclass `Pagy::RangeError`.
|
|
86
|
+
|
|
87
|
+
| Request | Out-of-range page | Any other bad option |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| JSON | 400 `{ error: "Page out of range", last_page: 4 }` | 400 `{ error: "Invalid pagination parameters", last_page: null }` |
|
|
90
|
+
| HTML, `html: :redirect` | Redirects to the last page, keeping every other param | Redirects without the page and limit params; 400 if there are none to drop |
|
|
91
|
+
| HTML, `html: :not_found` | 404 | 404 |
|
|
92
|
+
| HTML, `html: :my_method` | Calls `my_method(exception)` | Calls `my_method(exception)` |
|
|
93
|
+
|
|
94
|
+
`notice:` sets the redirect's flash. Pass a String, or a Proc for I18n: `notice: -> { t("flash.application.pagination_out_of_range") }`.
|
|
95
|
+
|
|
96
|
+
## Ordering
|
|
97
|
+
|
|
98
|
+
`paginate` and `Mcp.paginate` append the primary key in the direction of the last sort key:
|
|
99
|
+
|
|
100
|
+
```sql
|
|
101
|
+
ORDER BY occurred_at DESC -- before
|
|
102
|
+
ORDER BY occurred_at DESC, id DESC -- after
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
An unordered relation gets `ORDER BY id ASC`. The relation is left alone when the key is already in the order, and in these unsafe cases:
|
|
106
|
+
|
|
107
|
+
- it has a `GROUP BY`
|
|
108
|
+
- it uses `DISTINCT` with a custom `SELECT`
|
|
109
|
+
- it has a custom `FROM`
|
|
110
|
+
- the model's primary key is composite
|
|
111
|
+
|
|
112
|
+
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
|
+
|
|
114
|
+
## MCP tools
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# lib/mcp/tools/application_tool.rb
|
|
118
|
+
def paginate(scope, page: 1, per_page: DEFAULT_PER_PAGE)
|
|
119
|
+
StandardPagination::Mcp.paginate(scope, page:, per_page:, max_per_page: MAX_PER_PAGE)
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
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
|
+
|
|
125
|
+
## Not in 0.1
|
|
126
|
+
|
|
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
|
+
- **A shared React `<Pagination>` component.** It belongs in a frontend package, not a gem.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
bundle exec rspec # newest pagy
|
|
134
|
+
BUNDLE_GEMFILE=gemfiles/pagy-43.6.gemfile bundle exec rspec # oldest a consumer runs
|
|
135
|
+
bundle exec rubocop
|
|
136
|
+
```
|
data/Rakefile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module StandardPagination
|
|
2
|
+
class Config
|
|
3
|
+
# Rows per page when a call site passes no `limit:`.
|
|
4
|
+
attr_accessor :default_limit
|
|
5
|
+
# Largest page size a client may request when client limits are on.
|
|
6
|
+
attr_accessor :max_limit
|
|
7
|
+
# Request param a client sets the page size with (when allowed).
|
|
8
|
+
attr_accessor :limit_key
|
|
9
|
+
# Formats for which `paginate` honours the client's page size by default.
|
|
10
|
+
# HTML pages keep the size the call site chose.
|
|
11
|
+
attr_accessor :client_limit_formats
|
|
12
|
+
# Pagy raises Pagy::RangeError for a page past the last one, which
|
|
13
|
+
# Controller.rescue_pagination_errors turns into a redirect, 404 or 400.
|
|
14
|
+
# With it off, pagy serves an empty page instead.
|
|
15
|
+
attr_accessor :raise_range_error
|
|
16
|
+
# `paginate` appends the primary key to the ORDER BY unless told not to.
|
|
17
|
+
attr_accessor :tiebreak
|
|
18
|
+
# Raise (rather than warn) at boot when Pagy::OPTIONS still sets a
|
|
19
|
+
# client-limit option. Defaults to true in development and test only.
|
|
20
|
+
attr_accessor :strict_options
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@default_limit = 20
|
|
24
|
+
@max_limit = 100
|
|
25
|
+
@limit_key = "limit"
|
|
26
|
+
@client_limit_formats = %i[json]
|
|
27
|
+
@raise_range_error = true
|
|
28
|
+
@tiebreak = true
|
|
29
|
+
@strict_options = nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "action_controller"
|
|
3
|
+
|
|
4
|
+
module StandardPagination
|
|
5
|
+
# Include in ApplicationController (and any API base controller that does not
|
|
6
|
+
# inherit from it):
|
|
7
|
+
#
|
|
8
|
+
# include StandardPagination::Controller
|
|
9
|
+
# rescue_pagination_errors html: :redirect # last, after any catch-alls
|
|
10
|
+
#
|
|
11
|
+
# pagy, records = paginate(Device.where(...).order(created_at: :desc))
|
|
12
|
+
# render inertia: "devices/index", props: { devices: ..., pagination: pagination_data(pagy) }
|
|
13
|
+
module Controller
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
HTML_STRATEGIES = %i[redirect not_found].freeze
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
include Pagy::Method
|
|
20
|
+
|
|
21
|
+
class_attribute :_pagination_error_handling, instance_accessor: false, default: nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class_methods do
|
|
25
|
+
# Turns Pagy::OptionError (and its subclass Pagy::RangeError) into a
|
|
26
|
+
# response instead of a 500.
|
|
27
|
+
#
|
|
28
|
+
# JSON requests get a 400 with `last_page` (an integer for an
|
|
29
|
+
# out-of-range page, null for any other bad option). HTML requests,
|
|
30
|
+
# depending on `html:`:
|
|
31
|
+
#
|
|
32
|
+
# - `:redirect` redirects to the last page, keeping every other query
|
|
33
|
+
# param so filters survive. For a bad option other than the page, it
|
|
34
|
+
# drops the page and limit params instead; if there are none to drop,
|
|
35
|
+
# it responds 400 rather than redirect to the same failing URL.
|
|
36
|
+
# - `:not_found` responds 404 with an empty body.
|
|
37
|
+
# - a Symbol naming a controller method calls it with the exception,
|
|
38
|
+
# e.g. `html: :render_not_found` for an app's own 404 page.
|
|
39
|
+
#
|
|
40
|
+
# `notice:` is the flash for the redirect: a String, or a Proc evaluated
|
|
41
|
+
# in the controller (for I18n).
|
|
42
|
+
#
|
|
43
|
+
# Rails looks rescue handlers up from last-declared to first, so call
|
|
44
|
+
# this after any `rescue_from StandardError`. Calling it again replaces
|
|
45
|
+
# the earlier registration rather than stacking a second handler.
|
|
46
|
+
def rescue_pagination_errors(html: :redirect, notice: nil)
|
|
47
|
+
self._pagination_error_handling = { html:, notice: }
|
|
48
|
+
|
|
49
|
+
self.rescue_handlers = rescue_handlers.reject { |handler| handler.first == "Pagy::OptionError" }
|
|
50
|
+
rescue_from Pagy::OptionError, with: :handle_pagination_error
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Paginates `collection` with pagy and returns `[pagy, records]`.
|
|
57
|
+
#
|
|
58
|
+
# - `limit:` is the page size for this call (default: config.default_limit).
|
|
59
|
+
# - `client_limit:` lets the request override the page size via the
|
|
60
|
+
# config.limit_key param, capped at an Integer, or at config.max_limit
|
|
61
|
+
# for `true`. It defaults to on for config.client_limit_formats (JSON)
|
|
62
|
+
# and off for HTML, so an HTML page always shows the size it chose.
|
|
63
|
+
# A non-positive or non-numeric request value falls back to `limit:`.
|
|
64
|
+
# - `tiebreak:` appends the primary key to the ORDER BY (StableOrder).
|
|
65
|
+
# - `paginator:` is passed through to pagy (`:offset`, `:countless`, ...),
|
|
66
|
+
# as is any other pagy option (`page_key:`, `count:`, ...).
|
|
67
|
+
#
|
|
68
|
+
# Global client-limit options in Pagy::OPTIONS are neutralised for this
|
|
69
|
+
# call, so only `client_limit:` decides whether the client picks the size.
|
|
70
|
+
def paginate(collection, limit: nil, client_limit: nil, tiebreak: nil, paginator: :offset, **options)
|
|
71
|
+
config = StandardPagination.config
|
|
72
|
+
tiebreak = config.tiebreak if tiebreak.nil?
|
|
73
|
+
collection = StableOrder.apply(collection) if tiebreak
|
|
74
|
+
# pagy counts the underlying rows, not an inline DISTINCT's result set.
|
|
75
|
+
if paginator == :offset && !options.key?(:count) && StableOrder.inline_distinct?(collection)
|
|
76
|
+
options[:count] = Mcp.count(collection)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
pagy(
|
|
80
|
+
paginator,
|
|
81
|
+
collection,
|
|
82
|
+
raise_range_error: config.raise_range_error,
|
|
83
|
+
**options,
|
|
84
|
+
limit: resolve_pagination_limit(limit || config.default_limit, client_limit),
|
|
85
|
+
max_limit: nil,
|
|
86
|
+
client_limit: nil,
|
|
87
|
+
client_max_limit: nil
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def pagination_data(pagy)
|
|
92
|
+
StandardPagination.data(pagy)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_pagination_limit(limit, client_limit)
|
|
96
|
+
cap = pagination_client_limit_cap(client_limit)
|
|
97
|
+
return limit unless cap
|
|
98
|
+
|
|
99
|
+
requested = params[StandardPagination.config.limit_key].to_s
|
|
100
|
+
return limit unless requested.match?(/\A\d+\z/) && requested.to_i.positive?
|
|
101
|
+
|
|
102
|
+
[ requested.to_i, cap ].min
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pagination_client_limit_cap(client_limit)
|
|
106
|
+
client_limit = pagination_client_limit_format? if client_limit.nil?
|
|
107
|
+
|
|
108
|
+
case client_limit
|
|
109
|
+
when Integer then client_limit
|
|
110
|
+
when true then StandardPagination.config.max_limit
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def pagination_client_limit_format?
|
|
115
|
+
formats = StandardPagination.config.client_limit_formats
|
|
116
|
+
formats.any? { |format| request.format.public_send(:"#{format}?") }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def handle_pagination_error(exception)
|
|
120
|
+
handling = self.class._pagination_error_handling || {}
|
|
121
|
+
last_page = exception.is_a?(Pagy::RangeError) ? exception.pagy&.last : nil
|
|
122
|
+
|
|
123
|
+
if request.format.json?
|
|
124
|
+
return render json: { error: pagination_error_message(exception), last_page: }, status: :bad_request
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
case handling.fetch(:html, :redirect)
|
|
128
|
+
when :redirect then redirect_after_pagination_error(exception, last_page, handling[:notice])
|
|
129
|
+
when :not_found then head :not_found
|
|
130
|
+
else send(handling[:html], exception)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def pagination_error_message(exception)
|
|
135
|
+
exception.is_a?(Pagy::RangeError) ? "Page out of range" : "Invalid pagination parameters"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def redirect_after_pagination_error(exception, last_page, notice)
|
|
139
|
+
options = exception.pagy&.options || {}
|
|
140
|
+
page_key = (options[:page_key] || Pagy::DEFAULT[:page_key]).to_s
|
|
141
|
+
original = request.query_parameters
|
|
142
|
+
query = original.except(page_key)
|
|
143
|
+
|
|
144
|
+
if last_page
|
|
145
|
+
query[page_key] = last_page
|
|
146
|
+
else
|
|
147
|
+
query = query.except((options[:limit_key] || Pagy::DEFAULT[:limit_key]).to_s, StandardPagination.config.limit_key)
|
|
148
|
+
# Nothing left to drop: the bad option came from the call site or a
|
|
149
|
+
# param this gem doesn't know, so redirecting would only repeat it.
|
|
150
|
+
return head(:bad_request) if query == original
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Rebuild the path from the current route rather than echoing
|
|
154
|
+
# request.path, and refuse other hosts, so the redirect can only ever
|
|
155
|
+
# land on this app. The query is appended separately: merged into the
|
|
156
|
+
# url_for options, a ?action= or ?host= param would steer the route.
|
|
157
|
+
path = url_for(request.path_parameters.merge(only_path: true))
|
|
158
|
+
location = query.empty? ? path : "#{path}?#{query.to_query}"
|
|
159
|
+
notice = instance_exec(¬ice) if notice.respond_to?(:call)
|
|
160
|
+
flash = notice ? { notice: } : {}
|
|
161
|
+
redirect_to(location, allow_other_host: false, **flash)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module StandardPagination
|
|
2
|
+
# Offset pagination for MCP list tools, shared by every app's
|
|
3
|
+
# ApplicationTool. MCP clients already consume this shape, so it keeps the
|
|
4
|
+
# `per_page`/`total_count`/`total_pages` keys rather than the controller's.
|
|
5
|
+
module Mcp
|
|
6
|
+
DEFAULT_PER_PAGE = 20
|
|
7
|
+
MAX_PER_PAGE = 100
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Returns `[records, { page:, per_page:, total_count:, total_pages: }]`.
|
|
12
|
+
# A page past the end returns no records rather than raising.
|
|
13
|
+
def paginate(scope, page: 1, per_page: DEFAULT_PER_PAGE, max_per_page: MAX_PER_PAGE, tiebreak: nil)
|
|
14
|
+
tiebreak = StandardPagination.config.tiebreak if tiebreak.nil?
|
|
15
|
+
page = [ page.to_i, 1 ].max
|
|
16
|
+
per_page = per_page.to_i
|
|
17
|
+
per_page = DEFAULT_PER_PAGE unless per_page.positive?
|
|
18
|
+
per_page = per_page.clamp(1, max_per_page)
|
|
19
|
+
|
|
20
|
+
total = count(scope)
|
|
21
|
+
scope = StableOrder.apply(scope) if tiebreak
|
|
22
|
+
records = scope.offset((page - 1) * per_page).limit(per_page).to_a
|
|
23
|
+
|
|
24
|
+
[ records, { page:, per_page:, total_count: total, total_pages: (total.to_f / per_page).ceil } ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def count(scope)
|
|
28
|
+
# `select("DISTINCT ...")` returns fewer rows than count(:all) counts,
|
|
29
|
+
# so count the projected result set instead.
|
|
30
|
+
if StableOrder.inline_distinct?(scope)
|
|
31
|
+
return scope.klass.unscoped.from(scope.unscope(:order), :subquery).count
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
total = scope.count(:all)
|
|
35
|
+
# GROUP BY relations count to a Hash of group => count; the number of
|
|
36
|
+
# groups is the number of rows being paged over.
|
|
37
|
+
total.is_a?(Hash) ? total.size : total
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module StandardPagination
|
|
4
|
+
# Include in ApplicationRecord to get a `stable_order` scope:
|
|
5
|
+
#
|
|
6
|
+
# AuditLog.stable_order(occurred_at: :desc)
|
|
7
|
+
# # ORDER BY occurred_at DESC, id DESC
|
|
8
|
+
#
|
|
9
|
+
# Use it where an ordering leaves the paginator, e.g. a query object or an
|
|
10
|
+
# engine scope that a host app will page through. Controller#paginate and
|
|
11
|
+
# Mcp.paginate apply the same tie-breaker on their own.
|
|
12
|
+
module Model
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
class_methods do
|
|
16
|
+
def stable_order(*args)
|
|
17
|
+
StableOrder.apply(order(*args))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module StandardPagination
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
# Runs after the app's config/initializers, so it sees the app's
|
|
6
|
+
# Pagy::OPTIONS. Raises in development and test (so CI catches it) and
|
|
7
|
+
# logs elsewhere, unless config.strict_options says otherwise.
|
|
8
|
+
config.after_initialize do
|
|
9
|
+
strict = StandardPagination.config.strict_options
|
|
10
|
+
strict = Rails.env.local? if strict.nil?
|
|
11
|
+
|
|
12
|
+
message = StandardPagination.check_pagy_options!(raise_error: strict)
|
|
13
|
+
Rails.logger&.warn("[standard_pagination] #{message}") if message
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module StandardPagination
|
|
2
|
+
# Offset pagination over a non-unique ORDER BY can repeat or skip rows
|
|
3
|
+
# between pages: Postgres returns equal-sorting rows in any order, and that
|
|
4
|
+
# order can differ between the OFFSET 0 and OFFSET 20 queries. Appending the
|
|
5
|
+
# primary key makes the order total.
|
|
6
|
+
module StableOrder
|
|
7
|
+
DESC_PATTERN = /\bdesc\b/i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Returns the relation with the primary key appended to its ORDER BY,
|
|
12
|
+
# in the direction of the last existing sort key. Returns it unchanged
|
|
13
|
+
# when that is unnecessary or would be unsafe:
|
|
14
|
+
#
|
|
15
|
+
# - not an ActiveRecord::Relation (arrays, search results)
|
|
16
|
+
# - no single-column primary key
|
|
17
|
+
# - already ordered by the primary key
|
|
18
|
+
# - GROUP BY (the key isn't in the group, so Postgres rejects it)
|
|
19
|
+
# - DISTINCT with a custom SELECT, including `select("DISTINCT ...")`
|
|
20
|
+
# (the key may not be selected)
|
|
21
|
+
# - a custom FROM (the table name may not resolve)
|
|
22
|
+
def apply(relation)
|
|
23
|
+
return relation unless tiebreakable?(relation)
|
|
24
|
+
|
|
25
|
+
primary_key = relation.klass.primary_key
|
|
26
|
+
return relation if ordered_by?(relation, primary_key)
|
|
27
|
+
|
|
28
|
+
attribute = relation.klass.arel_table[primary_key]
|
|
29
|
+
relation.order(descending?(relation.order_values.last) ? attribute.desc : attribute.asc)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tiebreakable?(relation)
|
|
33
|
+
return false unless defined?(::ActiveRecord::Relation) && relation.is_a?(::ActiveRecord::Relation)
|
|
34
|
+
return false unless relation.klass.primary_key.is_a?(String)
|
|
35
|
+
return false if relation.group_values.any?
|
|
36
|
+
return false if relation.distinct_value && relation.select_values.any?
|
|
37
|
+
return false if inline_distinct?(relation)
|
|
38
|
+
|
|
39
|
+
relation.from_clause.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `select("DISTINCT category")` leaves distinct_value unset.
|
|
43
|
+
def inline_distinct?(relation)
|
|
44
|
+
relation.select_values.any? { |value| !value.is_a?(Symbol) && value.to_s.match?(/\bdistinct\b/i) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ordered_by?(relation, primary_key)
|
|
48
|
+
table = relation.klass.table_name
|
|
49
|
+
pattern = /\A\s*(?:"?#{Regexp.escape(table)}"?\.)?"?#{Regexp.escape(primary_key)}"?(?:\s+(?:asc|desc))?\s*\z/i
|
|
50
|
+
|
|
51
|
+
relation.order_values.any? do |value|
|
|
52
|
+
node = value.is_a?(Arel::Nodes::Ordering) ? value.expr : value
|
|
53
|
+
case node
|
|
54
|
+
when Arel::Attributes::Attribute
|
|
55
|
+
node.name.to_s == primary_key && node.relation.name.to_s == table
|
|
56
|
+
when String, Symbol
|
|
57
|
+
node.to_s.split(",").any? { |part| part.match?(pattern) }
|
|
58
|
+
else
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def descending?(value)
|
|
65
|
+
case value
|
|
66
|
+
when Arel::Nodes::Descending then true
|
|
67
|
+
when Arel::Nodes::Ordering then false
|
|
68
|
+
when String, Arel::Nodes::SqlLiteral then value.to_s.split(",").last.to_s.match?(DESC_PATTERN)
|
|
69
|
+
else false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "pagy"
|
|
2
|
+
require "active_support"
|
|
3
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
4
|
+
|
|
5
|
+
require "standard_pagination/version"
|
|
6
|
+
require "standard_pagination/config"
|
|
7
|
+
require "standard_pagination/stable_order"
|
|
8
|
+
require "standard_pagination/mcp"
|
|
9
|
+
|
|
10
|
+
module StandardPagination
|
|
11
|
+
class ConfigurationError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# The one metadata shape every Inertia prop and JSON list sends. pagy's
|
|
14
|
+
# `data_hash` omits a key whose value is nil, so `previous`/`next` are absent
|
|
15
|
+
# on the first/last page rather than null; consumers' TypeScript types
|
|
16
|
+
# already treat them as optional.
|
|
17
|
+
DATA_KEYS = %i[page pages count limit from to previous next].freeze
|
|
18
|
+
|
|
19
|
+
# Global pagy options that make pagy read the page size from the request.
|
|
20
|
+
# 43.6 reads `max_limit`; 43.7 reads `client_limit` and maps the other two
|
|
21
|
+
# onto it with a deprecation warning on every call.
|
|
22
|
+
CLIENT_LIMIT_OPTIONS = %i[max_limit client_limit client_max_limit].freeze
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def config
|
|
26
|
+
@config ||= Config.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure
|
|
30
|
+
yield config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset_config!
|
|
34
|
+
@config = Config.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def data(pagy)
|
|
38
|
+
pagy.data_hash(data_keys: DATA_KEYS)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Global pagy client-limit options left over from before this gem. They
|
|
42
|
+
# still apply to any bare `pagy(...)` call that bypasses
|
|
43
|
+
# Controller#paginate, letting clients pick the page size.
|
|
44
|
+
def leftover_client_limit_options(options = Pagy::OPTIONS)
|
|
45
|
+
CLIENT_LIMIT_OPTIONS.select { |key| options.key?(key) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check_pagy_options!(options = Pagy::OPTIONS, raise_error: true)
|
|
49
|
+
leftovers = leftover_client_limit_options(options)
|
|
50
|
+
return if leftovers.empty?
|
|
51
|
+
|
|
52
|
+
message = "Pagy::OPTIONS sets #{leftovers.map(&:inspect).join(', ')}, which lets clients choose the page " \
|
|
53
|
+
"size on every bare `pagy` call. Remove it and use `paginate(..., client_limit:)` instead."
|
|
54
|
+
raise ConfigurationError, message if raise_error
|
|
55
|
+
|
|
56
|
+
message
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
require "standard_pagination/controller"
|
|
62
|
+
require "standard_pagination/model"
|
|
63
|
+
require "standard_pagination/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: standard_pagination
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jaryl Sim
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: pagy
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '43.6'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '44'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '43.6'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '44'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activerecord
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '8.1'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '8.1'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: actionpack
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '8.1'
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '8.1'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: railties
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '8.1'
|
|
67
|
+
type: :runtime
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '8.1'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: brakeman
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
type: :development
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: bundler-audit
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: simplecov
|
|
104
|
+
requirement: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
type: :development
|
|
110
|
+
prerelease: false
|
|
111
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0'
|
|
116
|
+
description: StandardPagination wraps pagy with one controller concern, one metadata
|
|
117
|
+
shape, safe page-size handling, out-of-range error handling, deterministic tie-broken
|
|
118
|
+
ordering, and an MCP list helper shared across Rails apps.
|
|
119
|
+
email:
|
|
120
|
+
- code@jaryl.dev
|
|
121
|
+
executables: []
|
|
122
|
+
extensions: []
|
|
123
|
+
extra_rdoc_files: []
|
|
124
|
+
files:
|
|
125
|
+
- CHANGELOG.md
|
|
126
|
+
- LICENSE
|
|
127
|
+
- README.md
|
|
128
|
+
- Rakefile
|
|
129
|
+
- lib/standard_pagination.rb
|
|
130
|
+
- lib/standard_pagination/config.rb
|
|
131
|
+
- lib/standard_pagination/controller.rb
|
|
132
|
+
- lib/standard_pagination/mcp.rb
|
|
133
|
+
- lib/standard_pagination/model.rb
|
|
134
|
+
- lib/standard_pagination/railtie.rb
|
|
135
|
+
- lib/standard_pagination/stable_order.rb
|
|
136
|
+
- lib/standard_pagination/version.rb
|
|
137
|
+
homepage: https://github.com/rarebit-one/standard_pagination
|
|
138
|
+
licenses:
|
|
139
|
+
- MIT
|
|
140
|
+
metadata:
|
|
141
|
+
homepage_uri: https://github.com/rarebit-one/standard_pagination
|
|
142
|
+
source_code_uri: https://github.com/rarebit-one/standard_pagination
|
|
143
|
+
changelog_uri: https://github.com/rarebit-one/standard_pagination/blob/main/CHANGELOG.md
|
|
144
|
+
bug_tracker_uri: https://github.com/rarebit-one/standard_pagination/issues
|
|
145
|
+
rdoc_options: []
|
|
146
|
+
require_paths:
|
|
147
|
+
- lib
|
|
148
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '4.0'
|
|
153
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
154
|
+
requirements:
|
|
155
|
+
- - ">="
|
|
156
|
+
- !ruby/object:Gem::Version
|
|
157
|
+
version: '0'
|
|
158
|
+
requirements: []
|
|
159
|
+
rubygems_version: 4.0.10
|
|
160
|
+
specification_version: 4
|
|
161
|
+
summary: Shared pagination conventions for Rails apps, built on pagy.
|
|
162
|
+
test_files: []
|