Zendro-dev / graphql-server-model-codegen

Command line utility to auto-generate the structure files for a graphql server
MIT License
1 stars 2 forks source link

Add cursor based pagination (GraphQL connections) #44

Open asishallab opened 5 years ago

asishallab commented 5 years ago

Read this document to understand why we need cursor based pagination.

Implement it in both the GraphQL-server as well the single page application.

asishallab commented 5 years ago

Deviation from the standard GraphQL connection scheme

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.

Question: How to transform a 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. 
asishallab commented 5 years ago

How to use just the 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

Question: Shall we use just the 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.

asishallab commented 5 years ago

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() } )
   }
}
vsuaste commented 4 years ago

Commits # #ea6f28 #81bf33 #cfc96b #5db726 #c5dae7 #324181 #0e2af3 #4fd257 #213602 #d4e560

framirez07 commented 4 years ago

Reopened to add support for backward cursor based pagination.

framirez07 commented 4 years ago

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

asishallab commented 4 years ago

A few final issues remain to be tackled:

  1. backward pagination
  2. refresh, i.e. including the cursor

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.

Backward pagination

Validation of function-arguments

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. ...')
}

Decide whether forward or backward pagination is requested by the user

let isForwardPagination = (pagination === undefined || pagination.first)
// or even simpler 
let isBackwardPagination = (pagination.last != undefined)

Enable refresh of current page

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.

asishallab commented 4 years ago

Pagination when NOT sorting by id

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