Skip to content

Commit e935a2c

Browse files
author
Lee Richmond
committed
WIP - Add cursor-based stable ID pagination
This code is more for reference than review. The intent of this PR is to increase transparency and collaboration so we can get input from the community on the best direction to head - as with everything, there are tradeoffs. [This JSON:API "Cursor Pagination" Profile](https://jsonapi.org/profiles/ethanresnick/cursor-pagination/) explains one use case. The other is that we're in the process of adding GraphQL support to Graphiti, and cursors are more common in the GraphQL world. There are really two ways to do cursor-based pagination, and the JSON:API proposal only considers one. We can do this will offset-based cursors, or "stable IDS". This is best explained by [this post on Stable Relation Connections](https://graphql-ruby.org/pagination/stable_relation_connections) in GraphQL-Pro. Vanilla `graphql-ruby` does offset-based, Pro adds support for stable IDs. The third thing to consider is omitting cursors and supporting the `page[offset]` parameter. This is the simplest thing that supports the most use cases, though downsides are noted above. This PR implements stable IDs. That means we need to tell Graphiti which attributes are unique, incrementing keys. I've defaulted this to `id`, though it's notable that will need to be overridden if using non-incrementing UUIDs. So, the code: ```ruby class EmployeeResource < ApplicationResource # Tell Graphiti we are opting-in to cursor pagination self.cursor_paginatable = true # Tell Graphiti this is a stable ID that can be used as cursor sort :created_at, :datetime, cursor: true # Override the default cursor from 'id' to 'created_at' self.default_cursor = :created_at end ``` (*NB: One reason to have `cursor_paginatable` a separate flag from `default_cursor` is so `ApplicationResource` can say "all resources should support cursor pagination" but then throw helpful errors if `id` is a UUID or we elsewise don't have a default stable cursor defined*) This will cause us to render base64 encoded cursors: ```ruby { data: [ { id: "10", type: "employees", attributes: { ... }, meta: { cursor: "abc123" } } ] } ``` Which can be used as offsets with `?page[after]=abc123`. This would by default cause the SQL query `SELECT * FROM employees WHERE id > 10`. So far so good. The client might also pass a sort. So `sort=-id` (ID descending) would cause the reverse query `...where id < 10`. A little trickier: the client might pass a sort on an attribute that is not the cursor. This is one reason we want to flag the sorts with `cursor: true` - so the user can ```ruby sort :created_at, cursor: true ``` Then call ``` ?sort=created_at ``` Which will then use `created_at` as the cursor which means ``` ?sort=created_at&page[after]=abc123 ``` Will fire ``` SELECT * from employees where created_at > ? order by created_at asc ``` OK but now let's say the user tries to sort on something that ISN'T a stable ID: ``` ?sort=age ``` Under-the-hood we will turn this into a multi-sort like `age,id`. Then when paging `after` the SQL would be something like: ``` SELECT * FROM employees WHERE age > 40 OR (age = 40 AND id > 10) ORDER BY age ASC, id ASC ``` Finally, we need to consider `datetime`. By default we render to the second (e.g. `created_at.iso8601`) but to be a stable ID we need to render to nanosecond precision (`created_at.iso8601(6)`). So the serializer block will be honored, even if the attribute is unreadable: ```ruby attribute :timestamp, :datetime, readable: false do @object.created_at end ``` But we override the typecasting logic that would normally call `.iso8601` and instead call `.iso8601(6)`. For everything else *we omit typecasting entirely* since cursors should be referencing "raw" values and there is no need to case to and fro. There are three downsides to stable ID cursor pagination: * The developer needs to know all this, and has a little more work to do (like specifying `cursor: true`). * The `OR` logic above would be specific to the `ActiveRecord` adapter. Adapters in general do not have an `OR` concept, and I'm not sure there is a good general-purpose one. This means stable IDs only work when limiting sort capabilities and/or only using `ActiveRecord`. * The `before` cursor is complicated. We need to reverse the direction of the clauses, then re-reverse the records in memory. See [SQL Option 2: Double Reverse](https://blog.reactioncommerce.com/how-to-implement-graphql-pagination-and-sorting/). This feels like it might be buggy down the line. For these reasons I propose: * Default to offset-based cursors * Opt-in to stable IDs by specifying `.default_cursor` * This all goes through a `cursor_paginate` adapter method (alternative is we can call the relevant adapter methods like `filter_integer_gt` and introduce an "OR" concept, but this feels shaky). Implementing this will be non-trivial for non-AR datastores. * This means adapters need an "offset" concept This seems to give us the best balance of ease-of-use (offset-based) and opt-in power (stable-id-based). It also allows us to more easily say "all endpoints implement cursor-based pagination" (so we avoid having to remember to specify cursors in all resources). But, there are enough moving pieces here this is usually where I stop and get advice from people smarter than me. This is often @wadetandy but also includes basically anyone reading this PR. So, what are your thoughts?
1 parent 9ab7fa8 commit e935a2c

File tree

18 files changed

+911
-19
lines changed

18 files changed

+911
-19
lines changed

lib/graphiti.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def self.setup!
164164
require "graphiti/util/link"
165165
require "graphiti/util/remote_serializer"
166166
require "graphiti/util/remote_params"
167+
require "graphiti/util/cursor"
167168
require "graphiti/adapters/null"
168169
require "graphiti/adapters/graphiti_api"
169170
require "graphiti/extensions/extra_attribute"

lib/graphiti/adapters/abstract.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ def paginate(scope, current_page, per_page)
255255
raise "you must override #paginate in an adapter subclass"
256256
end
257257

258+
def cursor_paginate(scope, current_page, per_page)
259+
raise "you must override #cursor_paginate in an adapter subclass"
260+
end
261+
258262
# @param scope the scope object we are chaining
259263
# @param [Symbol] attr corresponding stat attribute name
260264
# @return [Numeric] the count of the scope

lib/graphiti/adapters/active_record.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,32 @@ def paginate(scope, current_page, per_page)
188188
scope.page(current_page).per(per_page)
189189
end
190190

191+
def cursor_paginate(scope, after, size)
192+
clause = nil
193+
after.each_with_index do |part, index|
194+
method = part[:direction] == "asc" ? :filter_gt : :filter_lt
195+
196+
if index.zero?
197+
clause = public_send \
198+
method,
199+
scope,
200+
part[:attribute],
201+
[part[:value]]
202+
else
203+
sub_scope = filter_eq \
204+
scope,
205+
after[index - 1][:attribute],
206+
[after[index - 1][:value]]
207+
sub_scope = filter_gt \
208+
sub_scope,
209+
part[:attribute],
210+
[part[:value]]
211+
clause = clause.or(sub_scope)
212+
end
213+
end
214+
paginate(clause, 1, size)
215+
end
216+
191217
# (see Adapters::Abstract#count)
192218
def count(scope, attr)
193219
if attr.to_sym == :total

lib/graphiti/configuration.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class Configuration
1313
attr_accessor :links_on_demand
1414
attr_accessor :pagination_links_on_demand
1515
attr_accessor :pagination_links
16+
attr_accessor :cursor_on_demand
1617
attr_accessor :typecast_reads
1718
attr_accessor :raise_on_missing_sidepost
1819

@@ -29,6 +30,7 @@ def initialize
2930
@links_on_demand = false
3031
@pagination_links_on_demand = false
3132
@pagination_links = false
33+
@cursor_on_demand = false
3234
@typecast_reads = true
3335
@raise_on_missing_sidepost = true
3436
self.debug = ENV.fetch("GRAPHITI_DEBUG", true)

lib/graphiti/errors.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,18 @@ def message
723723
end
724724
end
725725

726+
class UnsupportedCursorPagination < Base
727+
def initialize(resource)
728+
@resource = resource
729+
end
730+
731+
def message
732+
<<~MSG
733+
It looks like you are passing cursor pagination params, but #{@resource.class.name} does not support cursor pagination.
734+
MSG
735+
end
736+
end
737+
726738
class UnsupportedPageSize < Base
727739
def initialize(size, max)
728740
@size, @max = size, max

lib/graphiti/query.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ def pagination_links?
3939
end
4040
end
4141

42+
def cursor?
43+
return false if [:json, :xml, "json", "xml"].include?(params[:format])
44+
45+
if Graphiti.config.cursor_on_demand
46+
[true, "true"].include?(@params[:cursor])
47+
else
48+
@resource.cursor_paginatable?
49+
end
50+
end
51+
4252
def debug_requested?
4353
!!@params[:debug]
4454
end
@@ -185,6 +195,8 @@ def sorts
185195

186196
def pagination
187197
@pagination ||= begin
198+
valid_params = Scoping::Paginate::VALID_QUERY_PARAMS
199+
188200
{}.tap do |hash|
189201
(@params[:page] || {}).each_pair do |name, value|
190202
if legacy_nested?(name)
@@ -193,8 +205,9 @@ def pagination
193205
end
194206
elsif nested?(name)
195207
hash[name.to_s.split(".").last.to_sym] = value
196-
elsif top_level? && [:number, :size].include?(name.to_sym)
197-
hash[name.to_sym] = value.to_i
208+
elsif top_level? && valid_params.include?(name.to_sym)
209+
value = value.to_i if [:size, :number].include?(name.to_sym)
210+
hash[name.to_sym] = value
198211
end
199212
end
200213
end

lib/graphiti/resource/configuration.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ def remote=(val)
5454
}
5555
end
5656

57+
def cursor_paginatable=(val)
58+
super
59+
60+
unless default_cursor?
61+
if sorts.key?(:id)
62+
type = attributes[:id][:type]
63+
canonical = Graphiti::Types[type][:canonical_name]
64+
if canonical == :integer
65+
self.default_cursor = :id
66+
end
67+
end
68+
end
69+
end
70+
71+
def default_cursor=(val)
72+
super
73+
74+
if attributes.key?(val)
75+
sort val, cursorable: true
76+
else
77+
raise "friendly error about not an attribute"
78+
end
79+
end
80+
5781
def model
5882
klass = super
5983
unless klass || abstract_class?
@@ -82,6 +106,9 @@ class << self
82106
:serializer,
83107
:default_page_size,
84108
:default_sort,
109+
:default_cursor,
110+
:cursor_paginatable,
111+
:cursorable_attributes,
85112
:max_page_size,
86113
:attributes_readable_by_default,
87114
:attributes_writable_by_default,
@@ -120,6 +147,7 @@ def self.inherited(klass)
120147
unless klass.config[:attributes][:id]
121148
klass.attribute :id, :integer_id
122149
end
150+
123151
klass.stat total: [:count]
124152

125153
if defined?(::Rails) && ::Rails.env.development?
@@ -205,6 +233,7 @@ def config
205233
sort_all: nil,
206234
sorts: {},
207235
pagination: nil,
236+
cursor_pagination: nil,
208237
after_graph_persist: {},
209238
before_commit: {},
210239
after_commit: {},
@@ -252,6 +281,10 @@ def pagination
252281
config[:pagination]
253282
end
254283

284+
def cursor_pagination
285+
config[:cursor_pagination]
286+
end
287+
255288
def default_filters
256289
config[:default_filters]
257290
end
@@ -298,6 +331,10 @@ def pagination
298331
self.class.pagination
299332
end
300333

334+
def cursor_pagination
335+
self.class.cursor_pagination
336+
end
337+
301338
def attributes
302339
self.class.attributes
303340
end

lib/graphiti/resource/dsl.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def sort(name, *args, &blk)
6969
if get_attr(name, :sortable, raise_error: :only_unsupported)
7070
config[:sorts][name] = {
7171
proc: blk
72-
}.merge(opts.slice(:only))
72+
}.merge(opts.slice(:only, :cursorable))
7373
elsif (type = args[0])
7474
attribute name, type, only: [:sortable]
7575
sort(name, opts, &blk)
@@ -82,6 +82,10 @@ def paginate(&blk)
8282
config[:pagination] = blk
8383
end
8484

85+
def cursor_paginate(&blk)
86+
config[:cursor_pagination] = blk
87+
end
88+
8589
def stat(symbol_or_hash, &blk)
8690
dsl = Stats::DSL.new(new.adapter, symbol_or_hash)
8791
dsl.instance_eval(&blk) if blk

lib/graphiti/scoping/paginate.rb

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
module Graphiti
22
class Scoping::Paginate < Scoping::Base
33
DEFAULT_PAGE_SIZE = 20
4+
VALID_QUERY_PARAMS = [:number, :size, :before, :after]
45

56
def apply
67
if size > resource.max_page_size
78
raise Graphiti::Errors::UnsupportedPageSize
89
.new(size, resource.max_page_size)
910
elsif requested? && @opts[:sideload_parent_length].to_i > 1
1011
raise Graphiti::Errors::UnsupportedPagination
12+
elsif cursor? && !resource.cursor_paginatable?
13+
raise Graphiti::Errors::UnsupportedCursorPagination.new(resource)
1114
else
1215
super
1316
end
@@ -28,17 +31,57 @@ def apply?
2831

2932
# @return [Proc, Nil] the custom pagination proc
3033
def custom_scope
31-
resource.pagination
34+
cursor? ? resource.cursor_pagination : resource.pagination
3235
end
3336

3437
# Apply default pagination proc via the Resource adapter
3538
def apply_standard_scope
36-
resource.adapter.paginate(@scope, number, size)
39+
if cursor?
40+
# NB put in abstract adapter?
41+
42+
# if after_cursor
43+
# clause = nil
44+
# after_cursor.each_with_index do |part, index|
45+
# method = part[:direction] == "asc" ? :filter_gt : :filter_lt
46+
47+
# if index.zero?
48+
# clause = resource.adapter.public_send(method, @scope, part[:attribute], [part[:value]])
49+
# else
50+
# sub_scope = resource.adapter
51+
# .filter_eq(@scope, after_cursor[index-1][:attribute], [after_cursor[index-1][:value]])
52+
# sub_scope = resource.adapter.filter_gt(sub_scope, part[:attribute], [part[:value]])
53+
54+
# # NB - AR specific (use offset?)
55+
# # maybe in PR ask feedback
56+
# clause = clause.or(sub_scope)
57+
# end
58+
# end
59+
# @scope = clause
60+
# end
61+
# resource.adapter.paginate(@scope, 1, size)
62+
resource.adapter.cursor_paginate(@scope, after_cursor, size)
63+
else
64+
resource.adapter.paginate(@scope, number, size)
65+
end
3766
end
3867

3968
# Apply the custom pagination proc
4069
def apply_custom_scope
41-
resource.instance_exec(@scope, number, size, resource.context, &custom_scope)
70+
if cursor?
71+
resource.instance_exec \
72+
@scope,
73+
after_cursor,
74+
size,
75+
resource.context,
76+
&custom_scope
77+
else
78+
resource.instance_exec \
79+
@scope,
80+
number,
81+
size,
82+
resource.context,
83+
&custom_scope
84+
end
4285
end
4386

4487
private
@@ -58,5 +101,15 @@ def number
58101
def size
59102
(page_param[:size] || resource.default_page_size || DEFAULT_PAGE_SIZE).to_i
60103
end
104+
105+
def after_cursor
106+
if (after = page_param[:after])
107+
Util::Cursor.decode(resource, after)
108+
end
109+
end
110+
111+
def cursor?
112+
!!page_param[:after]
113+
end
61114
end
62115
end

lib/graphiti/scoping/sort.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ def apply_custom_scope
5151
private
5252

5353
def each_sort
54-
sort_param.each do |sort_hash|
54+
sorts = sort_param
55+
add_cursor_pagination_fallback(sorts)
56+
57+
sorts.each do |sort_hash|
5558
attribute = sort_hash.keys.first
5659
direction = sort_hash.values.first
5760
yield attribute, direction
@@ -82,5 +85,13 @@ def sort_hash(attr)
8285

8386
{key => value}
8487
end
88+
89+
def add_cursor_pagination_fallback(sorts)
90+
if sorts.present? && @resource.cursor_paginatable?
91+
sort_key = sorts.last.keys[0]
92+
cursorable = !!@resource.sorts[sort_key][:cursorable]
93+
sorts << {@resource.default_cursor => :asc} unless cursorable
94+
end
95+
end
8596
end
8697
end

0 commit comments

Comments
 (0)