diff --git a/packages/delegate/src/proxiedResult.ts b/packages/delegate/src/proxiedResult.ts index ccb8658a656..e3f987c1d30 100644 --- a/packages/delegate/src/proxiedResult.ts +++ b/packages/delegate/src/proxiedResult.ts @@ -79,7 +79,7 @@ export function mergeProxiedResults(target: any, ...sources: Array): any { const result = results.reduce(mergeDeep, target); result[FIELD_SUBSCHEMA_MAP_SYMBOL] = target[FIELD_SUBSCHEMA_MAP_SYMBOL] - ? mergeDeep(target[FIELD_SUBSCHEMA_MAP_SYMBOL], fieldSubschemaMap) + ? Object.assign({}, target[FIELD_SUBSCHEMA_MAP_SYMBOL], fieldSubschemaMap) : fieldSubschemaMap; const errors = sources.map((source: any) => (source instanceof Error ? source : source[ERROR_SYMBOL])); diff --git a/packages/delegate/src/results/handleObject.ts b/packages/delegate/src/results/handleObject.ts index 04557cb2bb1..9e4f426a0c6 100644 --- a/packages/delegate/src/results/handleObject.ts +++ b/packages/delegate/src/results/handleObject.ts @@ -87,15 +87,28 @@ function collectSubFields(info: GraphQLResolveInfo, typeName: string): Record { const fieldName = subFieldNodes[responseName][0].name.value; - if (selectionSetsByField[typeName] && selectionSetsByField[typeName][fieldName]) { + const typeSelectionSet = selectionSetsByType[typeName]; + if (typeSelectionSet != null) { subFieldNodes = collectFields( partialExecutionContext, type, - selectionSetsByField[typeName][fieldName], + typeSelectionSet, + subFieldNodes, + visitedFragmentNames + ); + } + const fieldSelectionSet = selectionSetsByField?.[typeName]?.[fieldName]; + if (fieldSelectionSet != null) { + subFieldNodes = collectFields( + partialExecutionContext, + type, + fieldSelectionSet, subFieldNodes, visitedFragmentNames ); diff --git a/packages/stitch/tests/typeMerging.example.test.ts b/packages/stitch/tests/typeMerging.example.test.ts new file mode 100644 index 00000000000..179a7ac26b2 --- /dev/null +++ b/packages/stitch/tests/typeMerging.example.test.ts @@ -0,0 +1,402 @@ +// Conversion of Apollo Federation demo from https://github.com/apollographql/federation-demo. +// See: https://github.com/ardatan/graphql-tools/issues/1697 + +import { graphql, ExecutionResult } from 'graphql'; + +import { makeExecutableSchema } from '@graphql-tools/schema'; + +import { stitchSchemas } from '../src/stitchSchemas'; + +describe('merging using type merging', () => { + + const users = [ + { + id: '1', + name: 'Ada Lovelace', + birthDate: '1815-12-10', + username: '@ada' + }, + { + id: '2', + name: 'Alan Turing', + birthDate: '1912-06-23', + username: '@complete', + }, + ]; + + const accountsSchema = makeExecutableSchema({ + typeDefs: ` + type Query { + me: User + _userById(id: ID!): User + } + type User { + id: ID! + name: String + username: String + } + `, + resolvers: { + Query: { + me: () => users[0], + _userById: (_root, { id }) => users.find(user => user.id === id), + }, + }, + }); + + const inventory = [ + { upc: '1', inStock: true }, + { upc: '2', inStock: false }, + { upc: '3', inStock: true } + ]; + + const inventorySchema = makeExecutableSchema({ + typeDefs: ` + type Product { + upc: String! + inStock: Boolean + shippingEstimate: Int + } + type Query { + _productByUpc( + upc: String!, + weight: Int, + price: Int + ): Product + } + `, + resolvers: { + Product: { + shippingEstimate: product => { + if (product.price > 1000) { + return 0 // free for expensive items + } + return Math.round(product.weight * 0.5) || null; // estimate is based on weight + } + }, + Query: { + _productByUpc: (_root, { upc, ...fields }) => ({ + ...inventory.find(product => product.upc === upc), + ...fields + }), + }, + }, + }); + + const products = [ + { + upc: '1', + name: 'Table', + price: 899, + weight: 100 + }, + { + upc: '2', + name: 'Couch', + price: 1299, + weight: 1000 + }, + { + upc: '3', + name: 'Chair', + price: 54, + weight: 50 + } + ]; + + const productsSchema = makeExecutableSchema({ + typeDefs: ` + type Query { + topProducts(first: Int = 5): [Product] + _productByUpc(upc: String!): Product + } + type Product { + upc: String! + name: String + price: Int + weight: Int + } + `, + resolvers: { + Query: { + topProducts: (_root, args) => products.slice(0, args.first), + _productByUpc: (_root, { upc }) => products.find(product => product.upc === upc), + } + }, + }); + + const usernames = [ + { id: '1', username: '@ada' }, + { id: '2', username: '@complete' }, + ]; + + const reviews = [ + { + id: '1', + authorId: '1', + product: { upc: '1' }, + body: 'Love it!', + }, + { + id: '2', + authorId: '1', + product: { upc: '2' }, + body: 'Too expensive.', + }, + { + id: '3', + authorId: '2', + product: { upc: '3' }, + body: 'Could be better.', + }, + { + id: '4', + authorId: '2', + product: { upc: '1' }, + body: 'Prefer something else.', + }, + ]; + + const reviewsSchema = makeExecutableSchema({ + typeDefs: ` + type Review { + id: ID! + body: String + author: User + product: Product + } + type User { + id: ID! + username: String + numberOfReviews: Int + reviews: [Review] + } + type Product { + upc: String! + reviews: [Review] + } + type Query { + _userById(id: ID!): User + _reviewById(id: ID!): Review + _productByUpc(upc: String!): Product + } + `, + resolvers: { + Review: { + author: (review) => ({ __typename: 'User', id: review.authorId }), + }, + User: { + reviews: (user) => reviews.filter(review => review.authorId === user.id), + numberOfReviews: (user) => reviews.filter(review => review.authorId === user.id).length, + username: (user) => { + const found = usernames.find(username => username.id === user.id) + return found ? found.username : null + }, + }, + Product: { + reviews: (product) => reviews.filter(review => review.product.upc === product.upc), + }, + Query: { + _reviewById: (_root, { id }) => reviews.find(review => review.id === id), + _userById: (_root, { id }) => ({ id }), + _productByUpc: (_, { upc }) => ({ upc }), + }, + } + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [ + { + schema: accountsSchema, + merge: { + User: { + fieldName: '_userById', + selectionSet: '{ id }', + args: ({ id }) => ({ id }) + } + } + }, + { + schema: inventorySchema, + merge: { + Product: { + fieldName: '_productByUpc', + selectionSet: '{ upc weight price }', + args: ({ upc, weight, price }) => ({ upc, weight, price }), + } + } + }, + { + schema: productsSchema, + merge: { + Product: { + fieldName: '_productByUpc', + selectionSet: '{ upc }', + args: ({ upc }) => ({ upc }), + } + } + }, + { + schema: reviewsSchema, + merge: { + User: { + fieldName: '_userById', + selectionSet: '{ id }', + args: ({ id }) => ({ id }), + }, + Product: { + fieldName: '_productByUpc', + selectionSet: '{ upc }', + args: ({ upc }) => ({ upc }), + }, + } + }], + mergeTypes: true, + }); + + test('can stitch from products to inventory schema', async () => { + const result = await graphql( + stitchedSchema, + ` + query { + topProducts { + upc + shippingEstimate + } + } + `, + ); + + const expectedResult = { + data: { + topProducts: [ + { shippingEstimate: 50, upc: '1' }, + { shippingEstimate: 0, upc: '2' }, + { shippingEstimate: 25, upc: '3' }, + ], + }, + }; + + expect(result).toEqual(expectedResult); + }); + + test('can stitch from accounts to reviews to products to inventory', async () => { + const result = await graphql( + stitchedSchema, + ` + query { + me { + reviews { + product { + upc + price + weight + } + } + } + } + `, + ); + + const expectedResult: ExecutionResult = { + data: { + me: { + reviews: [ + { product: { price: 899, upc: '1', weight: 100 } }, + { product: { price: 1299, upc: '2', weight: 1000 } }, + ], + } + }, + }; + + expect(result).toEqual(expectedResult); + }); + + test('can stitch from accounts to reviews to products to inventory', async () => { + const result = await graphql( + stitchedSchema, + ` + query { + me { + reviews { + product { + upc + price + weight + shippingEstimate + } + } + } + } + `, + ); + + const expectedResult: ExecutionResult = { + data: { + me: { + reviews: [ + { + product: { + price: 899, + upc: '1', + weight: 100, + shippingEstimate: 50, + }, + }, + { + product: { + price: 1299, + upc: '2', + weight: 1000, + shippingEstimate: 0, + } + }, + ], + }, + }, + }; + + expect(result).toEqual(expectedResult); + }); + + test('can stitch from accounts to reviews to products to inventory even when entire key not requested', async () => { + const result = await graphql( + stitchedSchema, + ` + query { + me { + reviews { + product { + upc + shippingEstimate + } + } + } + } + `, + ); + + const expectedResult: ExecutionResult = { + data: { + me: { + reviews: [ + { + product: { + upc: '1', + shippingEstimate: 50, + }, + }, + { + product: { + upc: '2', + shippingEstimate: 0, + } + }, + ], + }, + }, + }; + + expect(result).toEqual(expectedResult); + }); +}); diff --git a/packages/stitch/tests/typeMerging.test.ts b/packages/stitch/tests/typeMerging.test.ts index 4cdec1e7f9b..01318aa7c5a 100644 --- a/packages/stitch/tests/typeMerging.test.ts +++ b/packages/stitch/tests/typeMerging.test.ts @@ -11,119 +11,70 @@ import { delegateToSchema } from '@graphql-tools/delegate'; import { stitchSchemas } from '../src/stitchSchemas'; -let chirpSchema = makeExecutableSchema({ - typeDefs: ` - type Chirp { - id: ID! - text: String - author: User - coAuthors: [User] - authorGroups: [[User]] - } +describe('merging using type merging', () => { + test('works', async () => { + let chirpSchema = makeExecutableSchema({ + typeDefs: ` + type Chirp { + id: ID! + text: String + author: User + coAuthors: [User] + authorGroups: [[User]] + } - type User { - id: ID! - chirps: [Chirp] - } - type Query { - userById(id: ID!): User - } - `, -}); + type User { + id: ID! + chirps: [Chirp] + } + type Query { + userById(id: ID!): User + } + `, + }); -chirpSchema = addMocksToSchema({ schema: chirpSchema }); + chirpSchema = addMocksToSchema({ schema: chirpSchema }); -let authorSchema = makeExecutableSchema({ - typeDefs: ` - type User { - id: ID! - email: String - } - type Query { - userById(id: ID!): User - } - `, -}); + let authorSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + email: String + } + type Query { + userById(id: ID!): User + } + `, + }); -authorSchema = addMocksToSchema({ schema: authorSchema }); + authorSchema = addMocksToSchema({ schema: authorSchema }); -const stitchedSchema = stitchSchemas({ - subschemas: [ - { - schema: chirpSchema, - merge: { - User: { - fieldName: 'userById', - args: (originalResult) => ({ id: originalResult.id }), - selectionSet: '{ id }', + const stitchedSchema = stitchSchemas({ + subschemas: [ + { + schema: chirpSchema, + merge: { + User: { + fieldName: 'userById', + args: (originalResult) => ({ id: originalResult.id }), + selectionSet: '{ id }', + }, + }, }, - }, - }, - { - schema: authorSchema, - merge: { - User: { - fieldName: 'userById', - args: (originalResult) => ({ id: originalResult.id }), - selectionSet: '{ id }', + { + schema: authorSchema, + merge: { + User: { + fieldName: 'userById', + args: (originalResult) => ({ id: originalResult.id }), + selectionSet: '{ id }', + }, + }, }, - }, - }, - ], - mergeTypes: true, -}); - - -const failureSchema = addMocksToSchema({ - schema: makeExecutableSchema({ - typeDefs: ` - type User { - id: ID! - fail: Boolean - } - - type Query { - userById(id: ID!): User - } - ` - }), - mocks: { - Query() { - return ({ - userById() { throw new Error("failure message"); } - }) - }, - } -}) - -const stichedFailureSchema = stitchSchemas({ - subschemas: [ - { - schema: failureSchema, - merge: { - User: { - fieldName: 'userById', - selectionSet: '{ id }', - args: (originalResult) => ({ id: originalResult.id }), - } - } - }, - { - schema: stitchedSchema, - merge: { - User: { - fieldName: 'userById', - selectionSet: '{ id }', - args: (originalResult) => ({ id: originalResult.id }), - } - } - }, - ], - mergeTypes: true -}) + ], + mergeTypes: true, + }); -describe('merging using type merging', () => { - test('works', async () => { const query = ` query { userById(id: 5) { @@ -154,26 +105,82 @@ describe('merging using type merging', () => { expect(result.data.userById.chirps[1].author.email).not.toBe(null); }); - test("handle toplevel failures on subschema queries", async() => { + test("handle top level failures on subschema queries", async() => { + let userSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + email: String + } + type Query { + userById(id: ID!): User + } + `, + }); + + userSchema = addMocksToSchema({ schema: userSchema }); + + const failureSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + fail: Boolean + } + + type Query { + userById(id: ID!): User + } + `, + resolvers: { + Query: { + userById: () => { throw new Error("failure message"); }, + } + } + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [ + { + schema: failureSchema, + merge: { + User: { + fieldName: 'userById', + selectionSet: '{ id }', + args: (originalResult) => ({ id: originalResult.id }), + } + } + }, + { + schema: userSchema, + merge: { + User: { + fieldName: 'userById', + selectionSet: '{ id }', + args: (originalResult) => ({ id: originalResult.id }), + } + } + }, + ], + mergeTypes: true + }); + const query = ` query { userById(id: 5) { id email fail } } - ` + `; - const result = await graphql(stichedFailureSchema, query) + const result = await graphql(stitchedSchema, query); - expect(result.errors).not.toBeUndefined() - expect(result.data).toMatchObject({ userById: { fail: null }}) + expect(result.errors).not.toBeUndefined(); + expect(result.data).toMatchObject({ userById: { fail: null }}); expect(result.errors).toMatchObject([{ message: "failure message", path: ["userById", "fail"] - }]) - }) -}); + }]); + }); -describe('merge types and extend', () => { - test('should work', async () => { + test('merging types and type extensions should work together', async () => { const resultSchema = makeExecutableSchema({ typeDefs: ` type Query { @@ -299,5 +306,5 @@ describe('merge types and extend', () => { } expect(result).toEqual(expectedResult); - }) -}) + }); +});