Commit caaf203
Lee Richmond
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 caaf203
File tree
18 files changed
+911
-19
lines changed- lib
- graphiti
- adapters
- resource
- scoping
- util
- spec
- fixtures
- integration/rails
18 files changed
+911
-19
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
| 167 | + | |
167 | 168 | | |
168 | 169 | | |
169 | 170 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
255 | 255 | | |
256 | 256 | | |
257 | 257 | | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
258 | 262 | | |
259 | 263 | | |
260 | 264 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
188 | 188 | | |
189 | 189 | | |
190 | 190 | | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
191 | 217 | | |
192 | 218 | | |
193 | 219 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
16 | 17 | | |
17 | 18 | | |
18 | 19 | | |
| |||
29 | 30 | | |
30 | 31 | | |
31 | 32 | | |
| 33 | + | |
32 | 34 | | |
33 | 35 | | |
34 | 36 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
723 | 723 | | |
724 | 724 | | |
725 | 725 | | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
726 | 738 | | |
727 | 739 | | |
728 | 740 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
42 | 52 | | |
43 | 53 | | |
44 | 54 | | |
| |||
185 | 195 | | |
186 | 196 | | |
187 | 197 | | |
| 198 | + | |
| 199 | + | |
188 | 200 | | |
189 | 201 | | |
190 | 202 | | |
| |||
193 | 205 | | |
194 | 206 | | |
195 | 207 | | |
196 | | - | |
197 | | - | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
198 | 211 | | |
199 | 212 | | |
200 | 213 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
57 | 81 | | |
58 | 82 | | |
59 | 83 | | |
| |||
82 | 106 | | |
83 | 107 | | |
84 | 108 | | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
85 | 112 | | |
86 | 113 | | |
87 | 114 | | |
| |||
120 | 147 | | |
121 | 148 | | |
122 | 149 | | |
| 150 | + | |
123 | 151 | | |
124 | 152 | | |
125 | 153 | | |
| |||
205 | 233 | | |
206 | 234 | | |
207 | 235 | | |
| 236 | + | |
208 | 237 | | |
209 | 238 | | |
210 | 239 | | |
| |||
252 | 281 | | |
253 | 282 | | |
254 | 283 | | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
255 | 288 | | |
256 | 289 | | |
257 | 290 | | |
| |||
298 | 331 | | |
299 | 332 | | |
300 | 333 | | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
301 | 338 | | |
302 | 339 | | |
303 | 340 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
72 | | - | |
| 72 | + | |
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
85 | 89 | | |
86 | 90 | | |
87 | 91 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| 12 | + | |
| 13 | + | |
11 | 14 | | |
12 | 15 | | |
13 | 16 | | |
| |||
28 | 31 | | |
29 | 32 | | |
30 | 33 | | |
31 | | - | |
| 34 | + | |
32 | 35 | | |
33 | 36 | | |
34 | 37 | | |
35 | 38 | | |
36 | | - | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
37 | 66 | | |
38 | 67 | | |
39 | 68 | | |
40 | 69 | | |
41 | | - | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
42 | 85 | | |
43 | 86 | | |
44 | 87 | | |
| |||
58 | 101 | | |
59 | 102 | | |
60 | 103 | | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
61 | 114 | | |
62 | 115 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
55 | 58 | | |
56 | 59 | | |
57 | 60 | | |
| |||
82 | 85 | | |
83 | 86 | | |
84 | 87 | | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
85 | 96 | | |
86 | 97 | | |
0 commit comments