From 00176d8b2afe2d66471f795456aa2cefa58d836e Mon Sep 17 00:00:00 2001 From: Mick Hansen Date: Fri, 27 Nov 2015 10:31:33 +0100 Subject: [PATCH] added support for connectionFields and adding where queries to connections --- src/generateIncludes.js | 2 +- src/relay.js | 203 ++++++++++++---------- src/resolver.js | 12 +- test/integration/relay/connection.test.js | 105 +++++++++-- 4 files changed, 217 insertions(+), 105 deletions(-) diff --git a/src/generateIncludes.js b/src/generateIncludes.js index aa855327..30a969fd 100644 --- a/src/generateIncludes.js +++ b/src/generateIncludes.js @@ -33,7 +33,7 @@ export default function generateIncludes(simpleAST, type, root, options) { } } - if (isConnection(fieldType)) { + if (isConnection(fieldType) && fieldAST.fields.edges) { fieldAST = nodeAST(fieldAST); fieldType = nodeType(fieldType); } diff --git a/src/relay.js b/src/relay.js index 9f4c9d95..b239839b 100644 --- a/src/relay.js +++ b/src/relay.js @@ -18,6 +18,7 @@ import { import _ from 'lodash'; import resolver from './resolver'; +import simplifyAST from './simplifyAST'; class NodeTypeMapper { @@ -79,11 +80,11 @@ export function nodeType(connectionType) { return connectionType._fields.edges.type.ofType._fields.node.type; } -export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnum, before}) { +export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnum, before, connectionFields, where}) { const { edgeType, connectionType - } = connectionDefinitions({name, nodeType}); + } = connectionDefinitions({name, nodeType, connectionFields}); const model = target.target ? target.target : target; const SEPERATOR = '$'; @@ -128,111 +129,137 @@ export function sequelizeConnection({name, nodeType, target, orderBy: orderByEnu }; }; - return { - connectionType, - edgeType, - nodeType, - connectionArgs: $connectionArgs, - resolve: resolver(target, { - handleConnection: false, - include: true, - before: function (options, args) { - if (args.first || args.last) { - options.limit = parseInt(args.first || args.last, 10); - } + let argsToWhere = function (args) { + let result = {}; - if (!args.orderBy) { - args.orderBy = [orderByEnum._values[0].value]; - } else if (typeof args.orderBy === 'string') { - args.orderBy = [orderByEnum._nameLookup[args.orderBy].value]; - } + _.each(args, (value, key) => { + if (key in $connectionArgs) return; + _.assign(result, where(key, value)); + }); - let orderBy = args.orderBy; - let orderAttribute = orderByAttribute(orderBy); - let orderDirection = args.orderBy[0][1]; + return result; + }; - if (args.last) { - orderDirection = orderDirection === 'ASC' ? 'DESC' : 'ASC'; - } + let $resolver = resolver(target, { + handleConnection: false, + include: true, + before: function (options, args) { + if (args.first || args.last) { + options.limit = parseInt(args.first || args.last, 10); + } - options.order = [ - [orderAttribute, orderDirection] - ]; + if (!args.orderBy) { + args.orderBy = [orderByEnum._values[0].value]; + } else if (typeof args.orderBy === 'string') { + args.orderBy = [orderByEnum._nameLookup[args.orderBy].value]; + } - if (orderAttribute !== model.primaryKeyAttribute) { - options.order.push([model.primaryKeyAttribute, 'ASC']); - } + let orderBy = args.orderBy; + let orderAttribute = orderByAttribute(orderBy); + let orderDirection = args.orderBy[0][1]; - options.attributes.push(orderAttribute); + if (args.last) { + orderDirection = orderDirection === 'ASC' ? 'DESC' : 'ASC'; + } - if (model.sequelize.dialect.name === 'postgres' && options.limit) { - options.attributes.push([ - model.sequelize.literal('COUNT(*) OVER()'), - 'full_count' - ]); - } + options.order = [ + [orderAttribute, orderDirection] + ]; - if (args.after || args.before) { - let cursor = fromCursor(args.after || args.before); - let orderValue = cursor.orderValue; + if (orderAttribute !== model.primaryKeyAttribute) { + options.order.push([model.primaryKeyAttribute, 'ASC']); + } - if (model.rawAttributes[orderAttribute].type instanceof model.sequelize.constructor.DATE) { - orderValue = new Date(orderValue); - } + options.attributes.push(orderAttribute); - options.where = options.where || {}; + if (model.sequelize.dialect.name === 'postgres' && options.limit) { + options.attributes.push([ + model.sequelize.literal('COUNT(*) OVER()'), + 'full_count' + ]); + } - let where = { - $or: [ - { - [orderAttribute]: { - [orderDirection === 'ASC' ? '$gt' : '$lt']: orderValue - } - }, - { - [orderAttribute]: { - $eq: orderValue - }, - [model.primaryKeyAttribute]: { - $gt: cursor.id - } - } - ] - }; + options.where = argsToWhere(args); - // TODO, do a proper merge that won't kill another $or - _.assign(options.where, where); - } + if (args.after || args.before) { + let cursor = fromCursor(args.after || args.before); + let orderValue = cursor.orderValue; - return before(options); - }, - after: function (values, args) { - if (!args.orderBy) { - args.orderBy = [orderByEnum._values[0].value]; + if (model.rawAttributes[orderAttribute].type instanceof model.sequelize.constructor.DATE) { + orderValue = new Date(orderValue); } - let edges = values.map((value) => { - return { - cursor: toCursor(value, args.orderBy), - node: value - }; - }); + 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); + } - let firstEdge = edges[0]; - let lastEdge = edges[edges.length - 1]; - let fullCount = values[0] && values[0].dataValues.full_count && - parseInt(values[0].dataValues.full_count, 10) || 0; + return before(options); + }, + after: function (values, args, root, {source}) { + if (!args.orderBy) { + args.orderBy = [orderByEnum._values[0].value]; + } + let edges = values.map((value) => { return { - edges, - 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, - } + cursor: toCursor(value, args.orderBy), + node: value }; + }); + + let firstEdge = edges[0]; + let lastEdge = edges[edges.length - 1]; + let fullCount = values[0] && values[0].dataValues.full_count && + parseInt(values[0].dataValues.full_count, 10) || 0; + + return { + source, + args, + where: argsToWhere(args), + edges, + 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, + } + }; + } + }); + + return { + connectionType, + edgeType, + nodeType, + connectionArgs: $connectionArgs, + resolve: (source, args, info) => { + if (simplifyAST(info.fieldASTs[0], info).fields.edges) { + return $resolver(source, args, info); } - }) + + return { + source, + args, + where: argsToWhere(args) + }; + } }; } diff --git a/src/resolver.js b/src/resolver.js index 8a1dadf9..b9eb79b7 100644 --- a/src/resolver.js +++ b/src/resolver.js @@ -50,7 +50,8 @@ module.exports = function (target, options) { } return options.after(source.get(association.as), args, root, { ast: simpleAST, - type: type + type: type, + source: source }); } @@ -77,7 +78,8 @@ module.exports = function (target, options) { findOptions = options.before(findOptions, args, root, { ast: simpleAST, - type: type + type: type, + source: source }); if (!findOptions.order) { @@ -91,14 +93,16 @@ module.exports = function (target, options) { } return options.after(result, args, root, { ast: simpleAST, - type: type + type: type, + source: source }); }); } return model[list ? 'findAll' : 'findOne'](findOptions).then(function (result) { return options.after(result, args, root, { ast: simpleAST, - type: type + type: type, + source: source }); }); }; diff --git a/test/integration/relay/connection.test.js b/test/integration/relay/connection.test.js index 04d36d1e..d746f0fa 100644 --- a/test/integration/relay/connection.test.js +++ b/test/integration/relay/connection.test.js @@ -50,7 +50,8 @@ if (helper.sequelize.dialect.name === 'postgres') { }); this.Task = sequelize.define('task', { - name: Sequelize.STRING + name: Sequelize.STRING, + completed: Sequelize.BOOLEAN }, { timestamps: true }); @@ -111,7 +112,23 @@ if (helper.sequelize.dialect.name === 'postgres') { LATEST: {value: ['createdAt', 'DESC']}, NAME: {value: ['name', 'ASC']} } - }) + }), + connectionFields: () => ({ + totalCount: { + type: GraphQLInt, + resolve: function(connection) { + return connection.source.countTasks({ + where: connection.where + }); + } + } + }), + where: (key, value) => { + if (key === 'completed') { + value = !!value; + } + return {[key]: value}; + } }); this.userProjectConnection = sequelizeConnection({ @@ -133,7 +150,12 @@ if (helper.sequelize.dialect.name === 'postgres') { id: globalIdField(this.User.name), tasks: { type: this.userTaskConnection.connectionType, - args: this.userTaskConnection.connectionArgs, + args: { + ...this.userTaskConnection.connectionArgs, + completed: { + type: GraphQLBoolean + } + }, resolve: this.userTaskConnection.resolve }, projects: { @@ -173,15 +195,15 @@ if (helper.sequelize.dialect.name === 'postgres') { this.userA = await this.User.create({ [this.User.Tasks.as]: [ - {id: ++taskId, name: 'AAA', createdAt: new Date(now - 45000), projectId: this.projectA.get('id')}, - {id: ++taskId, name: 'ABA', createdAt: new Date(now - 40000), projectId: this.projectA.get('id')}, - {id: ++taskId, name: 'ABC', createdAt: new Date(now - 35000), projectId: this.projectA.get('id')}, - {id: ++taskId, name: 'ABC', createdAt: new Date(now - 30000), projectId: this.projectA.get('id')}, - {id: ++taskId, name: 'BAA', createdAt: new Date(now - 25000), projectId: this.projectA.get('id')}, - {id: ++taskId, name: 'BBB', createdAt: new Date(now - 20000), projectId: this.projectB.get('id')}, - {id: ++taskId, name: 'CAA', createdAt: new Date(now - 15000), projectId: this.projectB.get('id')}, - {id: ++taskId, name: 'CCC', createdAt: new Date(now - 10000), projectId: this.projectB.get('id')}, - {id: ++taskId, name: 'DDD', createdAt: new Date(now - 5000), projectId: this.projectB.get('id')} + {id: ++taskId, name: 'AAA', createdAt: new Date(now - 45000), projectId: this.projectA.get('id'), completed: false}, + {id: ++taskId, name: 'ABA', createdAt: new Date(now - 40000), projectId: this.projectA.get('id'), completed: true}, + {id: ++taskId, name: 'ABC', createdAt: new Date(now - 35000), projectId: this.projectA.get('id'), completed: true}, + {id: ++taskId, name: 'ABC', createdAt: new Date(now - 30000), projectId: this.projectA.get('id'), completed: false}, + {id: ++taskId, name: 'BAA', createdAt: new Date(now - 25000), projectId: this.projectA.get('id'), completed: false}, + {id: ++taskId, name: 'BBB', createdAt: new Date(now - 20000), projectId: this.projectB.get('id'), completed: true}, + {id: ++taskId, name: 'CAA', createdAt: new Date(now - 15000), projectId: this.projectB.get('id'), completed: true}, + {id: ++taskId, name: 'CCC', createdAt: new Date(now - 10000), projectId: this.projectB.get('id'), completed: false}, + {id: ++taskId, name: 'DDD', createdAt: new Date(now - 5000), projectId: this.projectB.get('id'), completed: false} ] }, { include: [this.User.Tasks] @@ -262,6 +284,33 @@ if (helper.sequelize.dialect.name === 'postgres') { 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, ` + { + user(id: ${this.userA.id}) { + tasks(first: 2, completed: true, orderBy: LATEST) { + edges { + node { + id + name + } + } + } + } + } + `); + + if (result.errors) throw new Error(result.errors[0].stack); + + expect(result.data.user.tasks.edges.length).to.equal(2); + expect(result.data.user.tasks.edges.map(task => { + return parseInt(fromGlobalId(task.node.id).id, 10); + })).to.deep.equal([ + this.userA.tasks[6].id, + this.userA.tasks[5].id, + ]); + }); + it('should support reverse pagination with last and orderBy', async function () { let firstThree = this.userA.tasks.slice(0, 3); let nextThree = this.userA.tasks.slice(3, 6); @@ -409,6 +458,38 @@ if (helper.sequelize.dialect.name === 'postgres') { //expect(sqlSpy.callCount).to.equal(2); }); + + it('should support connection fields', async function () { + let result = await graphql(this.schema, ` + { + user(id: ${this.userA.id}) { + tasks { + totalCount + } + } + } + `); + + if (result.errors) throw new Error(result.errors[0].stack); + + expect(result.data.user.tasks.totalCount).to.equal(9); + }); + + it('should support connection fields with args/where', async function () { + let result = await graphql(this.schema, ` + { + user(id: ${this.userA.id}) { + tasks(completed: true) { + totalCount + } + } + } + `); + + if (result.errors) throw new Error(result.errors[0].stack); + + expect(result.data.user.tasks.totalCount).to.equal(4); + }); }); }); } \ No newline at end of file