⚡️ Pro Feature ⚡️ This feature is bundled with GraphQL-Pro.
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” below if you wish to continue using offset-based pagination.
To enforce the opacity of your cursors, consider an encrypted encoder.
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:
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”.
Keep these points in mind when using value-based cursors:
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.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:
# 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.
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 encrypted cursors,%20you’ll%20need%20a%20%5Bversioned%20encoder%5D(/pro/encoders#versioning), too. This way, both unencrypted and encrypted cursors will be accepted! For example:
# Define an encrypted encoder for use with cursors:
EncryptedCursorEncoder = MyEncoder = GraphQL::Pro::Encoder.define do
key("f411f30495fe688cb349d...")
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.
If you don’t want GraphQL::Pro
’s new cursor behavior, re-register the offset-based RelationConnection
:
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
)
GraphQL::Pro::RelationConnection
supports ActiveRecord >= 4.1.0
.