diff --git a/src/relay.js b/src/relay.js index 4db11668..39b30b73 100644 --- a/src/relay.js +++ b/src/relay.js @@ -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); }); 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 } }; } diff --git a/test/integration/relay/connection.test.js b/test/integration/relay/connection.test.js index 7254538f..3e6c87f5 100644 --- a/test/integration/relay/connection.test.js +++ b/test/integration/relay/connection.test.js @@ -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; + }, 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; } }); @@ -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); + }); + }); + + 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 () { let result = await graphql(this.schema, ` {