Module: ConcernsOnRails::Controllers::CursorPaginatable
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/concerns_on_rails/controllers/cursor_paginatable.rb
Overview
Cursor (keyset) pagination for API controllers — the constant-time complement to Paginatable’s offset pagination. No COUNT query, stable under concurrent inserts, suitable for infinite scroll and sync feeds.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::CursorPaginatable
cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200
def index
render json: cursor_paginated(Article.all)
end
end
Reads params (the opaque token from X-Next-Cursor) and params. The primary key is always appended as a tiebreaker. Ordering columns are chosen in code (never from params), must live on the base model’s table, be selected by the relation, and should be NOT NULL.
Optional capabilities (all opt-in, defaults unchanged):
* bidirectional: true — mints X-Prev-Cursor / X-Has-Prev alongside the
next cursor, so clients can page back without keeping old tokens.
* order_presets: { newest: { created_at: :desc }, top: { score: :desc } }
— allow-listed, client-selectable named orderings (?order= picks a
preset NAME; columns stay code-chosen). Unknown names raise
InvalidOrderPreset (auto-rescued to a 400).
* predicate: :auto (default) — on adapters with row-value support
(PostgreSQL/MySQL/SQLite) and uniform directions, the keyset WHERE is
a tuple comparison `(a, b, id) > (x, y, z)` that walks a composite
index directly; everything else falls back to the OR-expansion.
Malformed or mismatched cursors raise CursorPaginatable::InvalidCursor; on real controllers a rescue_from is registered automatically and renders a 400 (via Respondable’s render_error when included). Override #render_invalid_cursor to customize the body. Cursors are opaque but NOT signed — boundary values are cast through the model’s attribute types and bound by Arel (no injection) and the relation’s scoping still applies, so treat a cursor as a page position, never an authorization boundary.
Do not combine with Controllers::Sortable#sorted — cursor_paginated uses reorder, which replaces any prior ORDER BY (including Models::Sortable’s default_scope). Pass ‘order:` per call instead.
Defined Under Namespace
Classes: InvalidCursor, InvalidOrderPreset
Constant Summary collapse
- DEFAULT_PER_PAGE =
25- DEFAULT_MAX_PER_PAGE =
200- VALID_DIRECTIONS =
%i[asc desc].freeze
- VALID_PREDICATES =
%i[auto row or].freeze
- CURSOR_DIRECTIONS =
%w[next prev].freeze
- ROW_PREDICATE_ADAPTERS =
Adapters whose SQL supports row-value (tuple) comparison: (a, b) > (x, y).
/postgres|mysql|trilogy|sqlite/i
Class Method Summary collapse
-
.normalize_order!(order) ⇒ Object
Normalizes Symbol / Array-of-Symbols / Hash order declarations to [[column, direction], …].
- .normalize_order_column!(col) ⇒ Object
-
.normalize_presets!(presets) ⇒ Object
Macro-time validation/normalization for order_presets / default_preset / predicate (module functions so class_methods stays thin).
- .resolve_default_preset!(presets, default_preset, order) ⇒ Object
- .validate_order_directions!(pairs) ⇒ Object
- .validate_order_sources!(presets, default_preset, order) ⇒ Object
- .validate_predicate!(predicate) ⇒ Object
Instance Method Summary collapse
-
#cursor_paginated(relation, order: nil, per_page: nil, bidirectional: nil) ⇒ Object
Run the keyset query (limit + 1 to detect has_more), set the standard response headers, and return the page as a loaded Array (laziness is impossible here: has_more detection materializes limit + 1 rows).
-
#cursor_pagination_meta(relation = nil, order: nil, per_page: nil, bidirectional: nil) ⇒ Object
With no arguments: the meta Hash memoized by the last cursor_paginated call (no extra query; nil if that call failed or never ran).
-
#render_invalid_cursor(error) ⇒ Object
Public override point (mirrors ErrorHandleable’s public handlers): delegates to Respondable#render_error when available.
-
#render_invalid_order_preset(error) ⇒ Object
Same override contract for unknown ?order= preset names.
Class Method Details
.normalize_order!(order) ⇒ Object
Normalizes Symbol / Array-of-Symbols / Hash order declarations to [[column, direction], …]. Raises ArgumentError on anything else.
71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 71 def self.normalize_order!(order) pairs = case order when Hash then order.map { |col, dir| [col.to_sym, dir.to_sym] } when Array then order.map { |col| [normalize_order_column!(col), :asc] } else [[normalize_order_column!(order), :asc]] end raise ArgumentError, "#{name}: at least one order column is required" if pairs.empty? validate_order_directions!(pairs) pairs end |
.normalize_order_column!(col) ⇒ Object
92 93 94 95 96 97 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 92 def self.normalize_order_column!(col) return col.to_sym if col.is_a?(Symbol) || col.is_a?(String) raise ArgumentError, "#{name}: order entries must be column names (Symbol/String); " \ "use a Hash like { column: :desc } to set directions" end |
.normalize_presets!(presets) ⇒ Object
Macro-time validation/normalization for order_presets / default_preset / predicate (module functions so class_methods stays thin).
101 102 103 104 105 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 101 def self.normalize_presets!(presets) raise ArgumentError, "#{name}: order_presets must be a non-empty Hash" unless presets.is_a?(Hash) && presets.any? presets.to_h { |key, order| [key.to_sym, normalize_order!(order)] } end |
.resolve_default_preset!(presets, default_preset, order) ⇒ Object
107 108 109 110 111 112 113 114 115 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 107 def self.resolve_default_preset!(presets, default_preset, order) validate_order_sources!(presets, default_preset, order) return nil unless presets key = (default_preset || presets.keys.first).to_sym raise ArgumentError, "#{name}: default_preset '#{key}' is not one of the order_presets" unless presets.key?(key) key end |
.validate_order_directions!(pairs) ⇒ Object
84 85 86 87 88 89 90 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 84 def self.validate_order_directions!(pairs) pairs.each do |col, dir| next if VALID_DIRECTIONS.include?(dir) raise ArgumentError, "#{name}: direction for '#{col}' must be :asc or :desc" end end |
.validate_order_sources!(presets, default_preset, order) ⇒ Object
117 118 119 120 121 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 117 def self.validate_order_sources!(presets, default_preset, order) raise ArgumentError, "#{name}: pass order: or order_presets:, not both" if order && presets raise ArgumentError, "#{name}: order: or order_presets: is required" if order.nil? && presets.nil? raise ArgumentError, "#{name}: default_preset: requires order_presets:" if default_preset && presets.nil? end |
.validate_predicate!(predicate) ⇒ Object
123 124 125 126 127 128 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 123 def self.validate_predicate!(predicate) predicate = predicate.to_sym return predicate if VALID_PREDICATES.include?(predicate) raise ArgumentError, "#{name}: predicate: must be one of #{VALID_PREDICATES.join(', ')}" end |
Instance Method Details
#cursor_paginated(relation, order: nil, per_page: nil, bidirectional: nil) ⇒ Object
Run the keyset query (limit + 1 to detect has_more), set the standard response headers, and return the page as a loaded Array (laziness is impossible here: has_more detection materializes limit + 1 rows). Raises InvalidCursor (rescued to a 400 on real controllers) on bad cursors.
177 178 179 180 181 182 183 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 177 def cursor_paginated(relation, order: nil, per_page: nil, bidirectional: nil) @cursor_pagination_meta = nil # never expose a previous call's meta after a failure result = cursor_paginate_result(relation, order: order, per_page: per_page, bidirectional: bidirectional) @cursor_pagination_meta = result[:meta] apply_cursor_pagination_headers(result[:meta]) result[:records] end |
#cursor_pagination_meta(relation = nil, order: nil, per_page: nil, bidirectional: nil) ⇒ Object
With no arguments: the meta Hash memoized by the last cursor_paginated call (no extra query; nil if that call failed or never ran). With a relation: runs the query and returns meta WITHOUT setting headers or touching the memo — for body-based pagination (Respondable’s meta:).
189 190 191 192 193 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 189 def (relation = nil, order: nil, per_page: nil, bidirectional: nil) return @cursor_pagination_meta if relation.nil? cursor_paginate_result(relation, order: order, per_page: per_page, bidirectional: bidirectional)[:meta] end |
#render_invalid_cursor(error) ⇒ Object
Public override point (mirrors ErrorHandleable’s public handlers): delegates to Respondable#render_error when available.
197 198 199 200 201 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 197 def render_invalid_cursor(error) return render_error(message: error., status: :bad_request, code: "invalid_cursor") if respond_to?(:render_error) render json: { success: false, error: { message: error., code: "invalid_cursor" } }, status: :bad_request end |
#render_invalid_order_preset(error) ⇒ Object
Same override contract for unknown ?order= preset names.
204 205 206 207 208 |
# File 'lib/concerns_on_rails/controllers/cursor_paginatable.rb', line 204 def render_invalid_order_preset(error) return render_error(message: error., status: :bad_request, code: "invalid_order_preset") if respond_to?(:render_error) render json: { success: false, error: { message: error., code: "invalid_order_preset" } }, status: :bad_request end |