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

Instance Method Summary collapse

Class Method Details

.normalize_order!(order) ⇒ Object

Normalizes Symbol / Array-of-Symbols / Hash order declarations to [[column, direction], …]. Raises ArgumentError on anything else.

Raises:

  • (ArgumentError)


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

Raises:

  • (ArgumentError)


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).

Raises:

  • (ArgumentError)


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

Raises:

  • (ArgumentError)


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

Raises:

  • (ArgumentError)


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

Raises:

  • (ArgumentError)


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 cursor_pagination_meta(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.message, status: :bad_request, code: "invalid_cursor") if respond_to?(:render_error)

  render json: { success: false, error: { message: error.message, 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.message, status: :bad_request, code: "invalid_order_preset") if respond_to?(:render_error)

  render json: { success: false, error: { message: error.message, code: "invalid_order_preset" } }, status: :bad_request
end