Stable Cursors for ActiveRecord

GraphQL::Pro includes a mechanism for serving stable cursors for ActiveRecord::Relations 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.

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:

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

Implementation Notes

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

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:

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

# Good: `category_id` is used to disambiguate any results with the same price:"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 encrypted cursors, you’ll need a versioned encoder, 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

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

MySchema = GraphQL::Schema.define do
  # Apply the versioned encoder:

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:

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

ActiveRecord Versions

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