Connections in GraphQL
To enable pagination, GraphQL utilizes a cursor-based system known as Connections. As outlined in the specification, the default connection should at least implement the following structure:
- Edges: These represent the actual data nodes in the connection. Each edge contains a cursor that points to the corresponding data item.
- Nodes: The actual data objects within each edge. These nodes contain the relevant information associated with the particular data item.
- Page Info: This provides information about the pagination, such as the existence of previous and next pages.
The specification states that each of the above can be extended with additional fields as the schema designer deems necessary. If you're interested in learning how to do this, continue reading.
An example GraphQL application
Let’s use this simple has_many :through
association as an example
The GraphQL code could look like this:
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
field :student, StudentType, null: true do
argument :id, ID, required: true
end
def student(id:)
::Student.find(id)
end
end
# app/graphql/types/student_type.rb
module Types
class StudentType < BaseObject
field :id, ID, null: false
field :name, String
field :courses, CourseType.connection_type, null: false
end
end
# app/graphql/types/course_type.rb
module Types
class CourseType < BaseObject
field :id, ID, null: false
field :title, String
end
end
A query with all fields required by GraphQL specification would look like this:
query {
student(id: 1) {
id
name
courses(first: 10) {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
Exposing information from the join table
The enrollment join table contains the field enrollment_date
that should be exposed in a query. According to the GraphQL-Ruby docs:
Edges can reveal (…) information about the relationship.
However, the documentation does not provide an example of how to do it. But don't worry, you can accomplish this in these 4 steps:
Create a Custom Connection Type class and:
- register a custom Edge class,
- define the
nodes
method (resolver method for default field).
# app/graphql/types/enrollment_connection.rb module Types class EnrollmentConnection < BaseConnection # register a custom Edge class edge_type(EnrollmentEdge) # This method is required for 'nodes' shorthand to work def nodes # object is GraphQL::Pagination wrapper for ActiveRecord::AssociationRelation object.edge_nodes.map(&:course) end end end
Create a Custom Edge Type class and:
- register a Node class,
- define of fields to expose,
- define resolver methods for defined fields,
- define a
node
method (resolver method for default field).
# app/graphql/types/enrollment_edge.rb module Types class EnrollmentEdge < BaseEdge # register a Node class node_type(Types::CourseType) # define field and resolver field :enrollment_date, GraphQL::Types::ISO8601Date, null: true def enrollment_date # object.node is an instance of Enrollment class object.node.enrollment_date end # point node method to the associated course def node object.node.course end end end
Register a custom Connection field on a Student Type
# app/graphql/types/student_type.rb module Types class StudentType < BaseObject field :id, ID, null: false field :name, String # connection is inferred from the type's name ending in *Connection field :courses, EnrollmentConnection def courses object.enrollments.includes(:course) end end end
Include
enrollmentDate
in the queryquery { student(id: 1) { id name courses(first: 10) { edges { cursor enrollmentDate # <--------- here node { id title } } } } }
That was easy!
Remark: Please be cautious of the N+1 issue that can be easily introduced here. Instead of using includes(:course)
, consider using GraphQL::Dataloader to batch-load tags from EnrollmentEdge#node
, or you can also explore the ar_lazy_preload gem for automagic preloading.
Showing the number of records
With the above structure, we can easily paginate by passing the cursor
to the after
argument until we reach the endCursor
or until hasNextPage
returns false
.
However, in this implementation, there is no way to determine the total number of pages or records.
To address this, modify the EnrollmentConnection
class. However, since this is a common requirement for accessing data, you can also add it to your BaseConnection
. Let's add a recordCount
field and a resolver.
# app/graphql/types/base_connection.rb
module Types
class BaseConnection < Types::BaseObject
include GraphQL::Types::Relay::ConnectionBehaviors
field :record_count, Integer
def record_count
object.items.size
end
end
end
Now add it to the query:
query {
student(id: 1) {
id
name
courses(first: 10) {
recordCount # <--------- here
edges {
cursor
enrollmentDate
node {
id
title
}
}
}
}
}
If you need, you can extend other parts of a Connection
like pageInfo
in a similar fashion. Here is the repository with the app used in the examples. Feel free to experiment with it. Be sure to check out branches other than 'main' as well.