Open asishallab opened 5 years ago
Our prime reason why we need cursor based pagination is explained in slides eight to ten in this presentation.
As documented here GraphQL suggests the usage of a connection
, which is in fact a complex query and return-object to be defined in the respective GraphQL schema. For our purposes we only want to adopt the usage of a cursor
instead of an offset
. We do not want to return the complex connection
object as for now. For some inspiration and doubts about the GraphQL connection approach see this GitHub conversation
This means that we need to change the paginate
functions generated to not expect an offset
argument, but a cursor
argument. Note that the new first
argument is just the renamed old limit
argument.
cursor
into an offset
?The cursor is the complete last object base64 encoded. It thus can be decoded and used for ordering purposes and enables identification of the next batch. Consider the following pseudo code:
# Dog model and its existing records:
[
{ name: 'Old yeller', age: 12, id: 1},
{ name: 'Old yeller', age: 14, id: 2},
{ name: 'Chiquito', age: 8, id: 3}
]
# Current cursor is e.g.
cursor = base64.encode( { name: 'Old yeller', age: 12, id: 1} )
# Then assume a GraphQL query is send with the above `cursor` and `after: 2`
# Within the paginate function do:
cursorObj = base64.decode( cursor )
# The paginate function needs to analyze the arguments provided to the `order` function, if any.
# Analyze the arguments of order(...) an for each ordered attribute _i_ do
# If desc -> comparator[i] will be '<'
# else comparator[i] will be '>'
# Based on these data the following SQL query can be constructed.
SELECT * FROM dogs WHERE dog.name <comparator[i]> <cursorObj.name> AND dog.id <comparator[i]> <cursorObj.id> ORDER BY dog.name, dog.id
# Note, that the above comparators have to be added for each attribute appearing in the arguments of `order(...)` and that finally the `id` has to be added as the last order attribute.
ID
attribute as cursor
and skip base64
encoding?If we just send the value of the ID
attribute of the last record in the current batch as the cursor
, we could obtain the associated record by a simple fetch from the database. Consider the above pseudo code and the following modification of it:
cursor = currentDog.id
cursorObj = models.dog.readById( cursor )
# continue as above
ID
or base64
encoded objects?Probably using just the ID
is more efficient. But, if the record associated with the cursor
(ID
) is deleted, the base64
encoding approach still works, while the cursor
as ID
approach does not work any more. Hence, the response to the question is: we need to use the current object base64
encoded as cursor
, not just its ID
.
So far:
books {
search( args ) {
return [ JavaSCript Plain Old Objects ]
}
toBase64() {
return base64( this.withoutAssocs )
}
}
person {
hasManyBooks
getBooks() {
return books.search( {person_id: this.id} )
}
toBase64() {
return base64( this.withoutAssocs )
}
}
// If we change to full connection model
// The above would break
person {
// ...
getBooks() {
return books.search{ {person_id: this.id} ).edges.map(x => { return x.node})
}
}
// If we want to avoid this, we could add simply a
// NEW function (resolver and query) that return a GraphQL-connection
// (https://graphql.org/learn/pagination/#pagination-and-edges)
// Define additional and new cursorPaginationArg to
// (https://graphql.org/learn/pagination/#pagination-and-edges)
{
after: // cursor base64 encoded (If NULL first page, else the base64-encoded 'last' record of the current page)
first: // limit - how many records to show per page (page-size)
}
books {
search( args ) {
// stays the same
}
// new connection-function (resolver)
graphQlConnection(searchArgs, connectionPaginationArg, orderArgs) {
// use the already implemented functions search, paginate, order
// and create a connection object that is returned
return {
totalCount
edges: [
{ // an edge
node {
// the book
}
cursor: // the book base64 encoded WITHOUT associations
},
// ...
],
pageInfo: {
endCursor
hasNextPage
}
}
// In DETAIL:
// First parse the arguments and obtain the requested records
// Consider the ordering as described in the Issue
records = searchAndOrderAndPaginateLikeDescribedInTheAboveCodeCommentAndIssueAndPutSomeMoreTextToConfuseEveryoneOk?()
// Now construct the "edges"
edges = transformRecordArrayToEdgesArr
// Function can be a helper that looks like this:
function transformRecordArrayToEdgesArr(recordsArr) {
return recordsArr.map( x => { return { node: x, cursor: x.toBase64() } )
}
}
Reopened to add support for backward cursor based pagination.
New commits:
on graphql-server-model-codegen: i44 - [in progress] - backward cursor based pagination support added
on graphql-server: i44 - [in progress] - backward cursor based pagination support added
A few final issues remain to be tackled:
Basis for the following definitions is Facebook Relay's proposed standard.
Note the usage of two cursor arguments after
and before
. They could be merged into a single cursor
argument, which'd fulfill the KISS, but alas we aim to keep with Facebook's proposed standard.
In the model implementation validate the provided arguments and raise an error, if invalid, i.e. non-sense arguments are provided.
let argsValid = (pagination === undefined) || (pagination.first && !pagination.before && !pagination.last) || (pagination.last && !pagination.after && !pagination.first)
if (!argsValid) {
throw new Error('Illlegal cursor based pagination arguments. ...')
}
let isForwardPagination = (pagination === undefined || pagination.first)
// or even simpler
let isBackwardPagination = (pagination.last != undefined)
To enable refreshing the current displayed page, we need to enable including the cursor. Add an optional argument includeCursor
to the schema, resolvers, and model API. If and only if includeCursor
is true
the current cursor (after
or before
) is included and thus the "offset arguments" last
or first
will be diminished by one to ensure the resulting number of records is equal to the respective offset argument. This is a tricky bit, because theoretically the cursor record might have been deleted during the time elapsed before the refresh. In that case, the pagination should not diminish the offset argument and work exactly as if includeCursor
was false
.
Consider the following example, where a data model has the attributes id
and name
and the user requests sorting by `name. We show, how to ensure correct result sets.
{id: 4, name: 'A'}
{id: 3, name: 'A'}
{id: 2, name: 'B'}
{id: 5, name: 'C'}
// cursor id=3
WHERE name > 'A' OR name = 'A' and id > 3 ORDER BY name, id
// include cursor - note "id >= 3"
WHERE name > 'A' OR name = 'A' and id >= 3 ORDER BY name, id
Read this document to understand why we need cursor based pagination.
Implement it in both the GraphQL-server as well the single page application.