-
Notifications
You must be signed in to change notification settings - Fork 161
Cursor Reconfigure For Order #165
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -128,8 +128,6 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu | |
| }); | ||
| } | ||
|
|
||
| let defaultOrderBy = orderByEnum._values[0].value; | ||
|
|
||
| before = before || ((options) => options); | ||
|
|
||
| let $connectionArgs = { | ||
|
|
@@ -143,20 +141,30 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu | |
| return orderBy[0][0]; | ||
| }; | ||
|
|
||
| let toCursor = function (value, orderBy) { | ||
| let id = value.get(model.primaryKeyAttribute); | ||
| let orderValue = value.get(orderByAttribute(orderBy)); | ||
| return base64(PREFIX + id + SEPERATOR + orderValue); | ||
| /** | ||
| * Creates a cursor given a item returned from the Database | ||
| * @param {Object} item sequelize model instance | ||
| * @param {Integer} index the index of this item within the results, 0 indexed | ||
| * @return {String} The Base64 encoded cursor string | ||
| */ | ||
| let toCursor = function (item, index) { | ||
| let id = item.get(model.primaryKeyAttribute); | ||
| return base64(PREFIX + id + SEPERATOR + index); | ||
| }; | ||
|
|
||
| /** | ||
| * Decode a cursor into its component parts | ||
| * @param {String} cursor Base64 encoded cursor | ||
| * @return {Object} Object containing ID and index | ||
| */ | ||
| let fromCursor = function (cursor) { | ||
| cursor = unbase64(cursor); | ||
| cursor = cursor.substring(PREFIX.length, cursor.length); | ||
| let [id, orderValue] = cursor.split(SEPERATOR); | ||
| let [id, index] = cursor.split(SEPERATOR); | ||
|
|
||
| return { | ||
| id, | ||
| orderValue | ||
| index | ||
| }; | ||
| }; | ||
|
|
||
|
|
@@ -171,13 +179,12 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu | |
| return result; | ||
| }; | ||
|
|
||
| let resolveEdge = function (item, args = {}, source) { | ||
| if (!args.orderBy) { | ||
| args.orderBy = [defaultOrderBy]; | ||
| } | ||
|
|
||
| let resolveEdge = function (item, index, queriedCursor, args = {}, source) { | ||
| let startIndex = 0; | ||
| if (queriedCursor) startIndex = Number(queriedCursor.index); | ||
| if (startIndex !== 0) startIndex++; | ||
| return { | ||
| cursor: toCursor(item, args.orderBy), | ||
| cursor: toCursor(item, index + startIndex), | ||
| node: item, | ||
| source: source | ||
| }; | ||
|
|
@@ -187,11 +194,10 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu | |
| handleConnection: false, | ||
| include: true, | ||
| list: true, | ||
| before: function (options, args, context, info) { | ||
| before: function (options, args, context) { | ||
| if (args.first || args.last) { | ||
| options.limit = parseInt(args.first || args.last, 10); | ||
| } | ||
|
|
||
| if (!args.orderBy) { | ||
| args.orderBy = [orderByEnum._values[0].value]; | ||
| } else if (typeof args.orderBy === 'string') { | ||
|
|
@@ -233,43 +239,22 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu | |
|
|
||
| if (args.after || args.before) { | ||
| let cursor = fromCursor(args.after || args.before); | ||
| let orderValue = cursor.orderValue; | ||
| let startIndex = Number(cursor.index); | ||
|
|
||
| if (model.rawAttributes[orderAttribute].type instanceof model.sequelize.constructor.DATE) { | ||
| orderValue = new Date(orderValue); | ||
| } | ||
|
|
||
| let slicingWhere = { | ||
| $or: [ | ||
| { | ||
| [orderAttribute]: { | ||
| [orderDirection === 'ASC' ? '$gt' : '$lt']: orderValue | ||
| } | ||
| }, | ||
| { | ||
| [orderAttribute]: { | ||
| $eq: orderValue | ||
| }, | ||
| [model.primaryKeyAttribute]: { | ||
| $gt: cursor.id | ||
| } | ||
| } | ||
| ] | ||
| }; | ||
|
|
||
| // TODO, do a proper merge that won't kill another $or | ||
| _.assign(options.where, slicingWhere); | ||
| if (startIndex > 0) options.offset = startIndex + 1; | ||
| } | ||
|
|
||
| // apply uniq to the attributes | ||
| options.attributes = _.uniq(options.attributes); | ||
| return before(options, args, root, context); | ||
| }, | ||
| after: function (values, args, root, {source}) { | ||
| var cursor = null; | ||
|
|
||
| if (args.after || args.before) { | ||
| cursor = fromCursor(args.after || args.before); | ||
| } | ||
|
|
||
| return before(options, args, context, info); | ||
| }, | ||
| after: function (values, args, context, {source}) { | ||
| let edges = values.map((value) => { | ||
| return resolveEdge(value, args, source); | ||
| let edges = values.map((value, idx) => { | ||
| return resolveEdge(value, idx, cursor, args, source); | ||
| }); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would the index not be incorrect if we're already querying after a cursor? It would be 0 where it should be N + 0
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The resolveEdge function takes care of the starting index. Though it could be modified instead of passing the cursor to the function to instead do all the calculations here and pass the resulting index to resolveEdge. Might be more efficient as well.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah right, you're taking the existing cursor and applying based on that. |
||
|
|
||
| let firstEdge = edges[0]; | ||
|
|
@@ -282,6 +267,11 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu | |
| if (model.sequelize.dialect.name === 'postgres' && (args.first || args.last)) { | ||
| if (fullCount === null || fullCount === undefined) throw new Error('No fullcount available'); | ||
| } | ||
| let hasMorePages = false; | ||
| if (args.first || args.last) { | ||
| let index = cursor ? Number(cursor.index) : 0; | ||
| hasMorePages = index + 1 + parseInt(args.first || args.last, 10) < fullCount; | ||
| } | ||
|
|
||
| return { | ||
| source, | ||
|
|
@@ -291,8 +281,8 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu | |
| pageInfo: { | ||
| startCursor: firstEdge ? firstEdge.cursor : null, | ||
| endCursor: lastEdge ? lastEdge.cursor : null, | ||
| hasPreviousPage: args.last !== null && args.last !== undefined ? fullCount > parseInt(args.last, 10) : false, | ||
| hasNextPage: args.first !== null && args.first !== undefined ? fullCount > parseInt(args.first, 10) : false, | ||
| hasPreviousPage: hasMorePages, | ||
| hasNextPage: hasMorePages | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import attributeFields from '../../../src/attributeFields'; | |
| import resolver from '../../../src/resolver'; | ||
| import {uniq} from 'lodash'; | ||
|
|
||
|
|
||
| const { | ||
| sequelize, | ||
| Promise | ||
|
|
@@ -18,13 +19,10 @@ import { | |
| } from '../../../src/relay'; | ||
|
|
||
| import { | ||
| GraphQLString, | ||
| GraphQLInt, | ||
| GraphQLFloat, | ||
| GraphQLNonNull, | ||
| GraphQLBoolean, | ||
| GraphQLEnumType, | ||
| GraphQLList, | ||
| GraphQLObjectType, | ||
| GraphQLSchema, | ||
| graphql | ||
|
|
@@ -121,9 +119,19 @@ if (helper.sequelize.dialect.name === 'postgres') { | |
| values: { | ||
| ID: {value: [this.Task.primaryKeyAttribute, 'ASC']}, | ||
| LATEST: {value: ['createdAt', 'DESC']}, | ||
| CUSTOM: {value: ['updatedAt', 'DESC']}, | ||
| NAME: {value: ['name', 'ASC']} | ||
| } | ||
| }), | ||
| before: (options) => { | ||
| if (options.order[0][0] === 'updatedAt') { | ||
| options.order = Sequelize.literal(` | ||
| CASE | ||
| WHEN completed = true THEN "createdAt" | ||
| ELSE "updatedAt" End ASC`); | ||
| } | ||
| return options; | ||
| }, | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can simply add the literal to the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Didn't work for me because literal returns an object with {val : }
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, i believe i have that in one of my projects. Having an object shouldn't matter, the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah alright, was sure i tried that out, but maybe i used a hook too. |
||
| connectionFields: () => ({ | ||
| totalCount: { | ||
| type: GraphQLInt, | ||
|
|
@@ -193,12 +201,12 @@ if (helper.sequelize.dialect.name === 'postgres') { | |
| orderBy: new GraphQLEnumType({ | ||
| name: 'Viewer' + this.Task.name + 'ConnectionOrder', | ||
| values: { | ||
| ID: {value: [this.Task.primaryKeyAttribute, 'ASC']}, | ||
| ID: {value: [this.Task.primaryKeyAttribute, 'ASC']} | ||
| } | ||
| }), | ||
| before: (options, args, root) => { | ||
| before: (options, args, context, {viewer}) => { | ||
| options.where = options.where || {}; | ||
| options.where.userId = root.viewer.get('id'); | ||
| options.where.userId = viewer.get('id'); | ||
| return options; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should probably be fixed to use context instead of rootValue for viewer.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any suggestions on how to do that? the test was failing when it was just using root, and root.viewer.get('id') saying viewer was not defined. Root would have been context in this case.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Usually the test needs to be changed from
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like it already follows that format.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Odd, viewer should be on context then. |
||
| } | ||
| }); | ||
|
|
@@ -340,7 +348,7 @@ if (helper.sequelize.dialect.name === 'postgres') { | |
| name: 'userProject', | ||
| nodeType: this.projectType, | ||
| target: this.User.Projects, | ||
| before(options){ | ||
| before(options) { | ||
| // compare a uniq set of attributes against what is returned by the sequelizeConnection resolver | ||
| let getUnique = uniq(options.attributes); | ||
| projectConnectionAttributesUnique = getUnique.length === options.attributes.length; | ||
|
|
@@ -384,7 +392,7 @@ if (helper.sequelize.dialect.name === 'postgres') { | |
| }) | ||
| }); | ||
|
|
||
| let result = await graphql(schema, ` | ||
| await graphql(schema, ` | ||
| { | ||
| user(id: ${this.userA.id}) { | ||
| projects { | ||
|
|
@@ -476,6 +484,96 @@ if (helper.sequelize.dialect.name === 'postgres') { | |
| expect(lastResult.data.user.tasks.pageInfo.hasNextPage).to.equal(false); | ||
| }); | ||
|
|
||
| it('should support in-query slicing and pagination with first and CUSTOM orderBy', async function () { | ||
| const correctOrder = await graphql(this.schema, ` | ||
| { | ||
| user(id: ${this.userA.id}) { | ||
| tasks(first: 9, orderBy: CUSTOM) { | ||
| edges { | ||
| cursor | ||
| node { | ||
| id | ||
| name | ||
| } | ||
| } | ||
| pageInfo { | ||
| hasNextPage | ||
| endCursor | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `); | ||
| const reordered = correctOrder.data.user.tasks.edges.map(({node}) => { | ||
| const targetId = fromGlobalId(node.id).id; | ||
| return this.userA.tasks.find(task => { | ||
| return task.id === Number(targetId); | ||
| }); | ||
| }); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we extend this test to also verify that the order from the custom orderBy is correct?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you mean that sequelize returns the items in the order we expect from the api? The rest of the test checks the order of the paged queries to match what is returned from relay queries.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rest of the test tests that paging works against the first result, however we haven't exactly verified the first result :) |
||
|
|
||
| let lastThree = reordered.slice(this.userA.tasks.length - 3, this.userA.tasks.length); | ||
| let nextThree = reordered.slice(this.userA.tasks.length - 6, this.userA.tasks.length - 3); | ||
| let firstThree = reordered.slice(this.userA.tasks.length - 9, this.userA.tasks.length - 6); | ||
|
|
||
| expect(firstThree.length).to.equal(3); | ||
| expect(nextThree.length).to.equal(3); | ||
| expect(lastThree.length).to.equal(3); | ||
|
|
||
|
|
||
| let verify = function (result, expectedTasks) { | ||
| if (result.errors) throw new Error(result.errors[0].stack); | ||
|
|
||
| var resultTasks = result.data.user.tasks.edges.map(function (edge) { | ||
| return edge.node; | ||
| }); | ||
|
|
||
| let resultIds = resultTasks.map((task) => { | ||
| return parseInt(fromGlobalId(task.id).id, 10); | ||
| }).sort(); | ||
|
|
||
| let expectedIds = expectedTasks.map(function (task) { | ||
| return task.get('id'); | ||
| }).sort(); | ||
|
|
||
| expect(resultTasks.length).to.equal(3); | ||
| expect(resultIds).to.deep.equal(expectedIds); | ||
| }; | ||
|
|
||
| let query = (after) => { | ||
| return graphql(this.schema, ` | ||
| { | ||
| user(id: ${this.userA.id}) { | ||
| tasks(first: 3, ${after ? 'after: "' + after + '", ' : ''} orderBy: CUSTOM) { | ||
| edges { | ||
| cursor | ||
| node { | ||
| id | ||
| name | ||
| } | ||
| } | ||
| pageInfo { | ||
| hasNextPage | ||
| endCursor | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `); | ||
| }; | ||
|
|
||
| let firstResult = await query(); | ||
| verify(firstResult, firstThree); | ||
| expect(firstResult.data.user.tasks.pageInfo.hasNextPage).to.equal(true); | ||
|
|
||
| let nextResult = await query(firstResult.data.user.tasks.pageInfo.endCursor); | ||
| verify(nextResult, nextThree); | ||
| expect(nextResult.data.user.tasks.pageInfo.hasNextPage).to.equal(true); | ||
|
|
||
| let lastResult = await query(nextResult.data.user.tasks.edges[2].cursor); | ||
| verify(lastResult, lastThree); | ||
| expect(lastResult.data.user.tasks.pageInfo.hasNextPage).to.equal(false); | ||
| }); | ||
|
|
||
| it('should support in-query slicing with user provided args/where', async function () { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like this test was changed although i don't think it was intentional. |
||
| let result = await graphql(this.schema, ` | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not following exactly what goes on here, if we receive a cursor we generate a new cursor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there is a cursor supplied to this function, we are simply getting the index of that cursor to determine where to start numbering the next indexes. Because index in this case is going to always start at 0 we need to increment startIndex if we aren't dealing with a startIndex of 0.
Ex: we get the first three items from query, they will have a index of 0,1 and 2 in their cursor returned from the api. If we then send the cursor of the 2 to the after clause, the next 3 returned will start numbering at 2. Without the startIndex++ check the next set of results would be 2, 3 and 4 which would be wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, of course. Maybe we should rename
cursortoqueriedCursorThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went ahead and renamed this to be clearer. I am holding off on committing until I can make any final changes we may need.