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 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,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -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(&notice) 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,3 @@
1
+ module StandardPagination
2
+ VERSION = "0.1.0"
3
+ 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: []