File: cursors.md

package info (click to toggle)
ruby-graphql 1.13.15-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 8,904 kB
  • sloc: ruby: 69,655; yacc: 444; javascript: 330; makefile: 6
file content (105 lines) | stat: -rw-r--r-- 4,884 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
---
layout: guide
doc_stub: false
search: true
section: GraphQL Pro
title: Stable Cursors for ActiveRecord
desc: Value-based cursors for stable pagination over ActiveRecord::Relations
index: 5
pro: true
---

__Note:__ See the new {% internal_link "stable relation connection", "/pagination/stable_relation_connections" %} guide for a more robust and flexible implementation of this feature.

-----

`GraphQL::Pro` includes a mechanism for serving _stable_ cursors for `ActiveRecord::Relation`s based on column values. If objects are created or destroyed during pagination, the list of items won't be disrupted.

A new `RelationConnection` is applied by default. It is backwards-compatible with existing offset-based cursors. See ["Opting Out"](#opting-out) below if you wish to continue using offset-based pagination.

To enforce the opacity of your cursors, consider an {% internal_link "encrypted encoder","/pro/encoders" %}.

## What's the difference?

The default `RelationConnection` (which turns an `ActiveRecord::Relation` into a Relay-compatible connection) uses _offset_ as a cursor. This naive approach is sufficient for many cases, but it's subject to a specific set of bugs.

Let's say you're looking at the second page of 10 items (`LIMIT 10 OFFSET 10`). During that time, one of the items on page 1 is deleted. When you navigate to page 3 (`LIMIT 10 OFFSET 20`), you'll actually _miss_ one item. The entire list shifted "up" one position when a previous item was deleted.

To solve this bug, we should use a _value_ to page through items (instead of _offset_). For example, if items are ordered by `id`, use the `id` for pagination:

```sql
LIMIT 10                      -- page 1
WHERE id > :last_id LIMIT 10  -- page 2
```

This way, even when items are added or removed, pagination will continue without interruption.

For more information about this issue, see ["Pagination: You're (Probably) Doing It Wrong"](https://coderwall.com/p/lkcaag/pagination-you-re-probably-doing-it-wrong).

## Implementation Notes

Keep these points in mind when using value-based cursors:

- For a given `ActiveRecord::Relation`, only columns of that specific model can be used in pagination. (This is because column names are turned into `WHERE` conditions.)
- `RelationConnection` may add an additional `primary_key` ordering to ensure that the cursor value is unique. This behavior is inspired by `Relation#reverse_order` which also assumes that `primary_key` is the default sort.

## Grouped Relations

When using a grouped `ActiveRecord::Relation`, include a unique ID in your sort to ensure that each row in the result has a unique cursor. For example:

```ruby
# Bad: If two results have the same `max(price)`,
# they will be identical from a pagination perspective:
Products.select("max(price) as price").group("category_id").order("price")

# Good: `category_id` is used to disambiguate any results with the same price:
Products.select("max(price) as price").group("category_id").order("price, category_id")
```

For ungrouped relations, this issue is handled automatically by adding the model's `primary_key` to the order values.

If you provide an unordered, grouped relation, `GraphQL::Pro::RelationConnection::InvalidRelationError` will be raised because an unordered relation _cannot_ be paginated in a stable way.

## Backwards Compatibility

`GraphQL::Pro`'s `RelationConnection` is backwards-compatible. If it receives an offset-based cursor, it uses that cursor for the next resolution, then returns value-based cursors in the next result.

If you're also switching to {% internal_link "encrypted cursors","/pro/encoders" %}, you'll need a {% internal_link "versioned encoder","/pro/encoders#versioning" %}, too. This way, _both_ unencrypted _and_ encrypted cursors will be accepted! For example:

```ruby
# Define an encrypted encoder for use with cursors:
EncryptedCursorEncoder = MyEncoder = GraphQL::Pro::Encoder.define do
  key("f411f30...")
end

# Make a versioned encoder combining new & old
VersionedCursorEncoder = GraphQL::Pro::Encoder.versioned(
  # New encrypted encoder:
  EncryptedCursorEncoder
  # Old plaintext encoder (this is the default):
  GraphQL::Schema::Base64Encoder
)

MySchema = GraphQL::Schema.define do
  # Apply the versioned encoder:
  cursor_encoder(VersionedCursorEncoder)
end
```

Now, _both_ unencrypted and encrypted cursors will be accepted.

## Opting Out

If you don't want `GraphQL::Pro`'s new cursor behavior, re-register the offset-based `RelationConnection`:

```ruby
MySchema = GraphQL::Schema.define { ... }
# Always use the offset-based connection, override `GraphQL::Pro::RelationConnection`
GraphQL::Relay::BaseConnection.register_connection_implementation(
  ActiveRecord::Relation, GraphQL::Relay::RelationConnection
)
```

## ActiveRecord Versions

`GraphQL::Pro::RelationConnection` supports ActiveRecord `>= 4.1.0`.