From 99d0da1d1697374a5efac8a3f21a400686ee362c Mon Sep 17 00:00:00 2001 From: Michael Beaumont Date: Fri, 20 Mar 2020 11:12:17 +0100 Subject: [PATCH 1/2] Add ability to add fields to edges --- src/composeWithConnection.js | 2 +- src/connectionResolver.js | 40 +++++++++++++++++++++++------------- src/types/connectionType.js | 20 ++++++++++-------- src/types/sortInputType.js | 2 +- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/composeWithConnection.js b/src/composeWithConnection.js index 5efbe34..07f0573 100644 --- a/src/composeWithConnection.js +++ b/src/composeWithConnection.js @@ -6,7 +6,7 @@ import type { ComposeWithConnectionOpts } from './connectionResolver'; export function composeWithConnection( typeComposer: ObjectTypeComposer, - opts: ComposeWithConnectionOpts + opts: ComposeWithConnectionOpts ): ObjectTypeComposer { if (!(typeComposer instanceof ObjectTypeComposer)) { throw new Error('You should provide ObjectTypeComposer instance to composeWithRelay method'); diff --git a/src/connectionResolver.js b/src/connectionResolver.js index 42706dd..dab2e18 100644 --- a/src/connectionResolver.js +++ b/src/connectionResolver.js @@ -8,18 +8,20 @@ import { type ResolverResolveParams, type ProjectionType, type ObjectTypeComposerArgumentConfigMap, + type ObjectTypeComposerFieldConfigMap, } from 'graphql-compose'; import type { GraphQLResolveInfo } from 'graphql-compose/lib/graphql'; import { prepareConnectionType } from './types/connectionType'; import { prepareSortType } from './types/sortInputType'; import { cursorToData, dataToCursor, type CursorDataType } from './cursor'; -export type ComposeWithConnectionOpts = { +export type ComposeWithConnectionOpts = { connectionResolverName?: string, findResolverName: string, countResolverName: string, sort: ConnectionSortMapOpts, defaultLimit?: ?number, + edgeFields?: ObjectTypeComposerFieldConfigMap, }; export type ConnectionSortOpts = { @@ -78,7 +80,7 @@ export type PageInfoType = { export function prepareConnectionResolver( tc: ObjectTypeComposer, - opts: ComposeWithConnectionOpts + opts: ComposeWithConnectionOpts ): Resolver { if (!(tc instanceof ObjectTypeComposer)) { throw new Error( @@ -133,7 +135,7 @@ export function prepareConnectionResolver( const defaultValue = firstField && sortEnumType.getField(firstField).value; return tc.schemaComposer.createResolver({ - type: prepareConnectionType(tc, opts.connectionResolverName), + type: prepareConnectionType(tc, opts.connectionResolverName, opts.edgeFields), name: opts.connectionResolverName || 'connection', kind: 'query', args: { @@ -197,19 +199,22 @@ export function prepareConnectionResolver( // combine top level projection // (maybe somebody add additional fields via resolveParams.projection) // and edges.node (record needed fields) - findManyParams.projection = { ...projection, ...projection.edges.node }; + const extraProjection = opts.edgeFields ? projection.edges : projection.edges.node; + findManyParams.projection = { ...projection, ...extraProjection }; } else { findManyParams.projection = { ...projection }; } - // Apply the rawQuery to the count to get accurate results with last and before + // Apply the rawQuery to the count to get accurate results with last and + // before const sortConfig: ?ConnectionSortOpts = findSortConfig(opts.sort, args.sort); if (sortConfig) { prepareRawQuery(resolveParams, sortConfig); } if (!first && last) { - // Get the number of edges targeted by the findMany resolver (not the whole count) + // Get the number of edges targeted by the findMany resolver (not the + // whole count) const filteredCountParams: $Shape> = { ...resolveParams, args: { @@ -243,7 +248,7 @@ export function prepareConnectionResolver( let skipIdx = -1; // eslint-disable-next-line - prepareCursorData = _ => { + prepareCursorData = (_) => { skipIdx += 1; return skip + skipIdx; }; @@ -268,13 +273,19 @@ export function prepareConnectionResolver( return Promise.all([findManyPromise, countPromise]) .then(([recordList, count]) => { - const edges = []; - // transform record to object { cursor, node } - recordList.forEach((record) => { - edges.push({ + // transform record to object { cursor, node, ...edge} + const edges = recordList.map((record) => { + const edge = { cursor: dataToCursor(prepareCursorData(record)), - node: record, - }); + node: opts.edgeFields ? record.node : record, + }; + if (opts.edgeFields) { + // Sometimes the value from `findMany` can't be spread + Object.keys(opts.edgeFields).forEach((field) => { + edge[field] = record[field]; + }); + } + return edge; }); return [edges, count]; }) @@ -419,7 +430,8 @@ export function emptyConnection(): ConnectionType { } export function findSortConfig(configs: ConnectionSortMapOpts, val: mixed): ?ConnectionSortOpts { - // Object.keys(configs).forEach(k => { // return does not works in forEach as I want + // Object.keys(configs).forEach(k => { // return does not works in forEach as + // I want for (const k in configs) { if (configs[k].value === val) { return configs[k]; diff --git a/src/types/connectionType.js b/src/types/connectionType.js index 8358224..9e14177 100644 --- a/src/types/connectionType.js +++ b/src/types/connectionType.js @@ -7,6 +7,7 @@ import { NonNullComposer, upperFirst, type SchemaComposer, + type ObjectTypeComposerFieldConfigMap, } from 'graphql-compose'; // This is required due compatibility with old client code bases @@ -47,20 +48,22 @@ export function preparePageInfoType( } export function prepareEdgeType( - typeComposer: ObjectTypeComposer + nodeTypeComposer: ObjectTypeComposer, + edgeFields?: ObjectTypeComposerFieldConfigMap ): ObjectTypeComposer { - const name = `${typeComposer.getTypeName()}Edge`; + const name = `${nodeTypeComposer.getTypeName()}Edge`; - if (typeComposer.schemaComposer.has(name)) { - return typeComposer.schemaComposer.getOTC(name); + if (nodeTypeComposer.schemaComposer.has(name)) { + return nodeTypeComposer.schemaComposer.getOTC(name); } - const edgeType = typeComposer.schemaComposer.createObjectTC({ + const edgeType = nodeTypeComposer.schemaComposer.createObjectTC({ name, description: 'An edge in a connection.', fields: { + ...edgeFields, node: { - type: new NonNullComposer(typeComposer), + type: new NonNullComposer(nodeTypeComposer), description: 'The item at the end of the edge', }, cursor: { @@ -75,7 +78,8 @@ export function prepareEdgeType( export function prepareConnectionType( typeComposer: ObjectTypeComposer, - resolverName: ?string + resolverName: ?string, + edgeFields?: ObjectTypeComposerFieldConfigMap ): ObjectTypeComposer { const name = `${typeComposer.getTypeName()}${upperFirst(resolverName || 'connection')}`; @@ -97,7 +101,7 @@ export function prepareConnectionType( }, edges: { type: new NonNullComposer( - new ListComposer(new NonNullComposer(prepareEdgeType(typeComposer))) + new ListComposer(new NonNullComposer(prepareEdgeType(typeComposer, edgeFields))) ), description: 'Information to aid in pagination.', }, diff --git a/src/types/sortInputType.js b/src/types/sortInputType.js index cab697d..43bac6f 100644 --- a/src/types/sortInputType.js +++ b/src/types/sortInputType.js @@ -6,7 +6,7 @@ import type { ConnectionSortOpts, ComposeWithConnectionOpts } from '../connectio export function prepareSortType( typeComposer: ObjectTypeComposer, - opts: ComposeWithConnectionOpts + opts: ComposeWithConnectionOpts ): EnumTypeComposer { if (!opts || !opts.sort) { throw new Error('Option `sort` should not be empty in composeWithConnection'); From a6455ba6aec83d8aef5e1d506b8b6085303050de Mon Sep 17 00:00:00 2001 From: Michael Beaumont Date: Wed, 22 Apr 2020 15:48:38 +0200 Subject: [PATCH 2/2] Add connectionResolver with edgeFields to tests --- src/__mocks__/userTC.js | 133 ++++++++++++++++-- .../connectionResolver-test.js.snap | 67 +++++++++ src/__tests__/connectionResolver-test.js | 28 +++- 3 files changed, 218 insertions(+), 10 deletions(-) diff --git a/src/__mocks__/userTC.js b/src/__mocks__/userTC.js index 70b9e71..1333342 100644 --- a/src/__mocks__/userTC.js +++ b/src/__mocks__/userTC.js @@ -29,7 +29,26 @@ export const UserType = new GraphQLObjectType({ }, }); +export const UserLinkType = new GraphQLObjectType({ + name: 'UserLink', + fields: { + id: { + type: GraphQLInt, + }, + type: { + type: GraphQLString, + }, + userId: { + type: GraphQLInt, + }, + otherUserId: { + type: GraphQLInt, + }, + }, +}); + export const userTC = schemaComposer.createObjectTC(UserType); +export const userLinkTC = schemaComposer.createObjectTC(UserLinkType); export const userList = [ { id: 1, name: 'user01', age: 11, gender: 'm' }, @@ -49,6 +68,11 @@ export const userList = [ { id: 13, name: 'user13', age: 45, gender: 'f' }, ]; +export const userLinkList = [ + { id: 1, type: 'likes', userId: 1, otherUserId: 2 }, + { id: 2, type: 'dislikes', userId: 2, otherUserId: 1 }, +]; + const filterArgConfig = { name: 'filter', type: new GraphQLInputObjectType({ @@ -64,30 +88,71 @@ const filterArgConfig = { }), }; -function filteredUserList(list, filter = {}) { - let result = list.slice(); +const filterEdgeArgConfig = { + name: 'filter', + type: new GraphQLInputObjectType({ + name: 'FilterNodeEdgeUserInput', + fields: { + edge: { + type: new GraphQLInputObjectType({ + name: 'FilterNodeEdgeEdgeUserInput', + fields: { + type: { + type: GraphQLString, + }, + }, + }), + }, + node: { + type: new GraphQLInputObjectType({ + name: 'FilterNodeNodeEdgeUserInput', + fields: { + gender: { + type: GraphQLString, + }, + age: { + type: GraphQLInt, + }, + }, + }), + }, + }, + }), +}; + +function filterUserLink(link, filter = {}) { + let pred = true; + if (filter.type) { + pred = pred && link.type === filter.type; + } + return pred; +} +function filterUser(user, filter = {}) { + let pred = true; if (filter.gender) { - result = result.filter((o) => o.gender === filter.gender); + pred = pred && user.gender === filter.gender; } if (filter.id) { if (filter.id.$lt) { - result = result.filter((o) => o.id < filter.id.$lt); + pred = pred && user.id < filter.id.$lt; } if (filter.id.$gt) { - result = result.filter((o) => o.id > filter.id.$gt); + pred = pred && user.id > filter.id.$gt; } } if (filter.age) { if (filter.age.$lt) { - result = result.filter((o) => o.age < filter.age.$lt); + pred = pred && user.age < filter.age.$lt; } if (filter.age.$gt) { - result = result.filter((o) => o.age > filter.age.$gt); + pred = pred && user.age > filter.age.$gt; } } - - return result; + return pred; +} +function filteredUserList(list, filter = {}) { + return list.slice().filter((o) => filterUser(o, filter)); } function sortUserList(list, sortValue = {}) { @@ -171,6 +236,56 @@ export const countResolver = schemaComposer.createResolver({ }); userTC.setResolver('count', countResolver); +function getThroughLinkResolver(list, filter) { + const nodeFilter = filter ? filter.node : {}; + const edgeFilter = filter ? filter.edge : {}; + return list + .map((link) => ({ + ...link, + node: userList.find((u) => u.id === link.otherUserId && filterUser(u, nodeFilter)), + })) + .filter((l) => !!l.node && filterUserLink(l, edgeFilter)); +} +export const findManyThroughLinkResolver = schemaComposer.createResolver({ + name: 'findManyThroughLink', + kind: 'query', + type: UserType, + args: { + filter: filterEdgeArgConfig, + limit: GraphQLInt, + skip: GraphQLInt, + }, + resolve: async (resolveParams) => { + const args = resolveParams.args || {}; + const { limit, skip } = args; + + let list = userLinkList.slice(); + + if (skip) { + list = list.slice(skip); + } + + if (limit) { + list = list.slice(0, limit); + } + return getThroughLinkResolver(list, args.filter); + }, +}); +userTC.setResolver('findManyThroughLink', findManyThroughLinkResolver); +export const countThroughLinkResolver = schemaComposer.createResolver({ + name: 'count', + kind: 'query', + type: GraphQLInt, + args: { + filter: filterEdgeArgConfig, + }, + resolve: async (resolveParams) => { + const args = resolveParams.args || {}; + return getThroughLinkResolver(userLinkList.slice(), args.filter).length; + }, +}); +userTC.setResolver('countThroughLink', countThroughLinkResolver); + export const sortOptions: ConnectionSortMapOpts = { ID_ASC: { value: { id: 1 }, diff --git a/src/__tests__/__snapshots__/connectionResolver-test.js.snap b/src/__tests__/__snapshots__/connectionResolver-test.js.snap index 7f00dbf..9837c91 100644 --- a/src/__tests__/__snapshots__/connectionResolver-test.js.snap +++ b/src/__tests__/__snapshots__/connectionResolver-test.js.snap @@ -4,6 +4,73 @@ exports[`connectionResolver "Relay Cursor Connections Specification (Pagination exports[`connectionResolver "Relay Cursor Connections Specification (Pagination algorithm)": ApplyCursorsToEdges(allEdges, before, after): should throw error if \`last\` is less than 0 1`] = `[Error: Argument \`last\` should be non-negative number.]`; +exports[`connectionResolver edges with data correctly handles filtering 1`] = ` +Object { + "count": 1, + "edges": Array [ + Object { + "cursor": "MA==", + "id": 1, + "node": Object { + "age": 12, + "gender": "m", + "id": 2, + "name": "user02", + }, + "otherUserId": 2, + "type": "likes", + "userId": 1, + }, + ], + "pageInfo": Object { + "endCursor": "MA==", + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "MA==", + }, +} +`; + +exports[`connectionResolver edges with data correctly resolves with edges 1`] = ` +Object { + "count": 2, + "edges": Array [ + Object { + "cursor": "MA==", + "id": 1, + "node": Object { + "age": 12, + "gender": "m", + "id": 2, + "name": "user02", + }, + "otherUserId": 2, + "type": "likes", + "userId": 1, + }, + Object { + "cursor": "MQ==", + "id": 2, + "node": Object { + "age": 11, + "gender": "m", + "id": 1, + "name": "user01", + }, + "otherUserId": 1, + "type": "dislikes", + "userId": 2, + }, + ], + "pageInfo": Object { + "endCursor": "MQ==", + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "MA==", + }, +} +`; + exports[`connectionResolver fallback logic (offset in cursor) should throw error if \`first\` is less than 0 1`] = `[Error: Argument \`first\` should be non-negative number.]`; exports[`connectionResolver fallback logic (offset in cursor) should throw error if \`last\` is less than 0 1`] = `[Error: Argument \`last\` should be non-negative number.]`; diff --git a/src/__tests__/connectionResolver-test.js b/src/__tests__/connectionResolver-test.js index 8279ce8..88428ef 100644 --- a/src/__tests__/connectionResolver-test.js +++ b/src/__tests__/connectionResolver-test.js @@ -3,7 +3,7 @@ import { Resolver } from 'graphql-compose'; import { GraphQLInt, GraphQLString } from 'graphql-compose/lib/graphql'; -import { userTC, userList, sortOptions } from '../__mocks__/userTC'; +import { userTC, userLinkTC, userList, sortOptions } from '../__mocks__/userTC'; import { dataToCursor } from '../cursor'; import { prepareConnectionResolver, prepareRawQuery, preparePageInfo } from '../connectionResolver'; @@ -829,4 +829,30 @@ describe('connectionResolver', () => { expect(data.pageInfo.hasPreviousPage).toBe(true); }); }); + + describe('edges with data', () => { + const edgeDataResolver = prepareConnectionResolver(userTC, { + countResolverName: 'countThroughLink', + findResolverName: 'findManyThroughLink', + sort: sortOptions, + defaultLimit: 5, + edgeFields: userLinkTC.getFields(), + }); + it('correctly resolves with edges', async () => { + const data = await edgeDataResolver.resolve({ + args: {}, + projection: { count: true, edges: true }, + }); + expect(data.edges.length).toBe(2); + expect(data).toMatchSnapshot(); + }); + it('correctly handles filtering', async () => { + const data = await edgeDataResolver.resolve({ + args: { filter: { edge: { type: 'likes' } } }, + projection: { count: true, edges: true }, + }); + expect(data.edges.length).toBe(1); + expect(data).toMatchSnapshot(); + }); + }); });