From c899f0a9495ec955b0a6303bc413a9ad97990112 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Thu, 13 Aug 2020 01:05:42 -0400 Subject: [PATCH 01/13] initial example. --- website/docs/stitch-merging-types.md | 348 ++++++++++++++++++++++++--- 1 file changed, 317 insertions(+), 31 deletions(-) diff --git a/website/docs/stitch-merging-types.md b/website/docs/stitch-merging-types.md index fc2de78bfae..1e7ce730b1c 100644 --- a/website/docs/stitch-merging-types.md +++ b/website/docs/stitch-merging-types.md @@ -1,86 +1,372 @@ --- id: stitch-merging-types -title: Merging types -sidebar_label: Merging types +title: Type Merging +sidebar_label: Type Merging --- -## Merging types +Type merging offers an alternative strategy to [schema extensions]() for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. -We are still stuck exposing the `authorId` field within the `Chirp` type even though we actually just want to include an `author`. We can always use transforms to filter out the `authorId` field, but it would be nice if this could be made more ergonomical. It also makes stitching somewhat more difficult when the authorId is not directly exposed -- for example, in a situation where we do not control the remote subschema in question. +Using type merging frequently eliminates the need for schema extensions, though does not preclude their use. Merging can often outperform extensions by resolving entire portions of an object tree with a single delegation. More broadly, it offers similar capabilities to [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) while using only plain GraphQL and bare-metal configuration. -What is the chirps subschema also had its own self-contained user concept, and we just wanted to combine the relevant fields from both subschemas? +### Basic example + +Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may post listings for sale to other users. Separating listings from users might look like this: ```js -let chirpSchema = makeExecutableSchema({ - typeDefs: ` - type Chirp { - id: ID! - text: String - author: User - } +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { addMocksToSchema } from '@graphql-tools/mock'; +let usersSchema = makeExecutableSchema({ + typeDefs: ` type User { id: ID! - chirps: [Chirp] + username: String! + email: String! } type Query { - chirpById(id: ID!): Chirp - chirpsByUserId(id: ID!): [Chirp] userById(id: ID!): User } ` }); -chirpSchema = addMocksToSchema({ schema: chirpSchema }); - -// Mocked author schema -let authorSchema = makeExecutableSchema({ +let listingsSchema = makeExecutableSchema({ typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + seller: User! + buyer: User + } + type User { id: ID! - email: String + listings: [Listing]! } type Query { + listingById(id: ID!): Listing userById(id: ID!): User } ` }); -authorSchema = addMocksToSchema({ schema: authorSchema }); +// just mock the schemas for now to make them return dummy data +usersSchema = addMocksToSchema({ schema: usersSchema }); +listingsSchema = addMocksToSchema({ schema: listingsSchema }); ``` -This can now be accomplished by turning on type merging! +Note that both services define a `User` type. While the users service manages information about the user account, the listings service appropraitely provides listings associated with the user ID. Now we just have to configure the `User` type to be merged: ```js -const stitchedSchema = stitchSchemas({ +import { stitchSchemas } from '@graphql-tools/stitch'; + +const gatewaySchema = stitchSchemas({ subschemas: [ { - schema: chirpSchema, + schema: usersSchema, merge: { User: { fieldName: 'userById', - args: (originalResult) => ({ id: originalResult.id }), selectionSet: '{ id }', - }, - }, + args: (userEntity) => ({ id: userEntity.id }), + } + } }, { - schema: authorSchema, + schema: listingsSchema, merge: { User: { fieldName: 'userById', - args: (originalResult) => ({ id: originalResult.id }), selectionSet: '{ id }', - }, + args: (userEntity) => ({ id: userEntity.id }), + } + } + }, + ], + mergeTypes: true +}); +``` + +That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type entity (i.e.: version of the type—services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly query a complete `User`, regardless of which service provides the initial entity. + +The `User` schema now looks like this in the gateway: + +```graphql +type User { + id: ID! + username: String! + email: String! + listings: [Listing]! +} +``` + +#### With batching + +One big problem in the example above is that subschemas are being queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving lists. This can be avoided by setting up array queries for batching: + +```graphql +usersByIds(ids: [ID!]!): [User]! +``` + +Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then translates the list of keys into query arguments: + +```js +const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: listingsSchema, + merge: { + User: { + fieldName: 'usersByIds', + selectionSet: '{ id }', + key: ({ id }) => id, + args: (ids) => ({ ids }), + } + } + }, + { + schema: usersSchema, + merge: { + User: { + fieldName: 'usersByIds', + selectionSet: '{ id }', + key: ({ id }) => id, + args: (ids) => ({ ids }), + } + } + }, + ], + mergeTypes: true +}); +``` + +#### Services without a database + +```js +const listings = [ + { id: '1', description: 'Junk for sale', price: 10.99, sellerId: '1', buyerId: '2' }, + { id: '2', description: 'Spare parts', price: 200.99, sellerId: '1', buyerId: null }, +]; + +const listingsSchema = makeExecutableSchema({ + typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + seller: User! + buyer: User + } + + type User { + id: ID! + listings: [Listing]! + } + + type Query { + listingsByIds(ids: [ID!]!): [Listing]! + usersByIds(ids: [ID!]!): [User]! + } + `, + resolvers: { + Query: { + listingsByIds: (root, args) => args.ids.map(id => listings.find(listing => listing.id === id)), + usersByIds: (root, args) => args.ids.map(id => { id }), + }, + User: { + listings(user) { + return listings.filter(listing => listing.sellerId === user.id); + } + } + } +}); +``` + +### Unidrectional merging + +```js +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { addMocksToSchema } from '@graphql-tools/mock'; +import { stitchSchemas } from '@graphql-tools/stitch'; + +let listingsSchema = makeExecutableSchema({ + typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + seller: User! + buyer: User + } + + type User { + id: ID! + } + + type Query { + listingById(id: ID!): Listing + } + ` +}); + +let usersSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String! + } + + type Query { + usersByIds(ids: [ID!]!): [User]! + } + ` +}); + +listingsSchema = addMocksToSchema({ schema: listingsSchema }); +usersSchema = addMocksToSchema({ schema: usersSchema }); + +const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: listingsSchema, + }, + { + schema: usersSchema, + merge: { + User: { + selectionSet: '{ id }', + fieldName: 'usersByIds', + key: ({ id }) => id, + args: (ids) => ({ ids }), + } + } + }, + ], + mergeTypes: true +}); +``` + +### Injected representations + +```js +const listings = [ + { id: '1', description: 'Junk for sale', price: 10.99, sellerId: '1', buyerId: '2' }, + { id: '2', description: 'Spare parts', price: 200.99, sellerId: '1', buyerId: null }, +]; + +const listingsSchema = makeExecutableSchema({ + typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + sellerId: ID! + buyerId: ID + } + + type Query { + listingsByIds(ids: [ID!]!): [Listing]! + } + `, + resolvers: { + Query: { + listingsByIds: (root, args) => args.ids.map(id => listings.find(listing => listing.id === id)), + } + } +}); + +const users = [ + { id: '1', username: 'bigvader23' }, + { id: '2', username: 'hanshotfirst' }, +]; + +const usersSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String! + } + + type Listing { + id: ID! + seller: User! + buyer: User + } + + input ListingRepresentation { + id: ID! + sellerId: ID + buyerId: ID + } + + type Query { + _listingsByRepresentations(representations: [ListingRepresentation!]!): [Listing]! + } + `, + resolvers: { + Query: { + _listingsByRepresentations: (obj, args) => args.representations, + }, + Listing: { + seller(listing) { + return users.find(user => user.id === listing.sellerId); }, + buyer(listing) { + return users.find(user => user.id === listing.buyerId) || null; + } + } + } +}); + +const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: listingsSchema, + merge: { + Listing: { + selectionSet: '{ id }', + fieldName: 'listingsByIds', + args: (obj) => ({ id: obj.id }), + } + } + }, + { + schema: usersSchema, + merge: { + Listing: { + selectionSet: '{ id sellerId buyerId }', + fieldName: '_listingsByRepresentations', + key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + args: (representations) => ({ representations }), + } + } }, ], - mergeTypes: true, + mergeTypes: true }); ``` +```js +const usersSubschema = { + schema: usersSchema, + merge: { + Listing: { + selectionSet: '{ id }', + fields: { + seller: { selectionSet: '{ sellerId }' }, + buyer: { selectionSet: '{ buyerId }' }, + }, + fieldName: '_listingsByRepresentations', + key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + args: (representations) => ({ representations }), + } + } +} +``` + + The `merge` property on the `SubschemaConfig` object determines how types are merged, and is a map of `MergedTypeConfig` objects: ```ts From cbd9f77cf0906cdf03578aca5581aa3d96e1d1ec Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 00:28:14 -0400 Subject: [PATCH 02/13] add patterns writeup. --- website/docs/stitch-merging-types.md | 103 +++++++++++++++++++-------- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/website/docs/stitch-merging-types.md b/website/docs/stitch-merging-types.md index 1e7ce730b1c..c568e6a604d 100644 --- a/website/docs/stitch-merging-types.md +++ b/website/docs/stitch-merging-types.md @@ -4,11 +4,11 @@ title: Type Merging sidebar_label: Type Merging --- -Type merging offers an alternative strategy to [schema extensions]() for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. +Type merging offers an alternative strategy to [schema extensions](/docs/stitch-schema-extensions) for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. Using type merging frequently eliminates the need for schema extensions, though does not preclude their use. Merging can often outperform extensions by resolving entire portions of an object tree with a single delegation. More broadly, it offers similar capabilities to [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) while using only plain GraphQL and bare-metal configuration. -### Basic example +## Basic example Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may post listings for sale to other users. Separating listings from users might look like this: @@ -89,9 +89,9 @@ const gatewaySchema = stitchSchemas({ }); ``` -That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type entity (i.e.: version of the type—services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly query a complete `User`, regardless of which service provides the initial entity. +That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type _entity_, or—partial type (services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly resolve a complete `User`, regardless of which service provides the initial entity. -The `User` schema now looks like this in the gateway: +The `User` schema is now structured like this in the gateway: ```graphql type User { @@ -102,15 +102,15 @@ type User { } ``` -#### With batching +### With batching -One big problem in the example above is that subschemas are being queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving lists. This can be avoided by setting up array queries for batching: +A big inefficiency in the example above is that subschemas are queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving arrays of objects. This inefficiency can be avoided by turning our single-record queries into array queries that facillitate batching: ```graphql usersByIds(ids: [ID!]!): [User]! ``` -Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then translates the list of keys into query arguments: +Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then transforms the list of picked keys into query arguments: ```js const gatewaySchema = stitchSchemas({ @@ -142,10 +142,12 @@ const gatewaySchema = stitchSchemas({ }); ``` -#### Services without a database +### Partial types without a database + +It's easy to imagine that each `usersByIds` query has a backing database table used to lookup the requested users. However, this is frequently not the case. For example, here's a simple resolver implementation that demonstrates how `User.listings` could be resolved without the listing service having any database concept of a User: ```js -const listings = [ +const listingsData = [ { id: '1', description: 'Junk for sale', price: 10.99, sellerId: '1', buyerId: '2' }, { id: '2', description: 'Spare parts', price: 200.99, sellerId: '1', buyerId: null }, ]; @@ -172,25 +174,28 @@ const listingsSchema = makeExecutableSchema({ `, resolvers: { Query: { - listingsByIds: (root, args) => args.ids.map(id => listings.find(listing => listing.id === id)), + listingsByIds: (root, args) => args.ids.map(id => listingsData.find(listing => listing.id === id)), usersByIds: (root, args) => args.ids.map(id => { id }), }, User: { listings(user) { - return listings.filter(listing => listing.sellerId === user.id); + return listingsData.filter(listing => listing.sellerId === user.id); } } } }); ``` -### Unidrectional merging +In this example, `usersByIds` simply converts the submitted IDs into stub records that get resolved as the `User` type. This pattern can be expanded even futher using [injected entities](). -```js -import { makeExecutableSchema } from '@graphql-tools/schema'; -import { addMocksToSchema } from '@graphql-tools/mock'; -import { stitchSchemas } from '@graphql-tools/stitch'; +## Merging patterns + + +### Stub types + +The simplest pattern for providing a type across subschemas is to simply include an ID-only stub representing it in any schema that uses it, and allow for external data to be merged onto the stub. For example: +```js let listingsSchema = makeExecutableSchema({ typeDefs: ` type Listing { @@ -201,6 +206,7 @@ let listingsSchema = makeExecutableSchema({ buyer: User } + # stubbed type... type User { id: ID! } @@ -223,10 +229,11 @@ let usersSchema = makeExecutableSchema({ } ` }); +``` -listingsSchema = addMocksToSchema({ schema: listingsSchema }); -usersSchema = addMocksToSchema({ schema: usersSchema }); +When a stubbed type includes no other data beyond a shared key, then the type may be considered _unidirectional_ to the service—that is, the service holds no unique data that would require an inbound request to fetch it. In these cases, `merge` config may be omitted entirely for the stubbed type: +```js const gatewaySchema = stitchSchemas({ subschemas: [ { @@ -248,7 +255,13 @@ const gatewaySchema = stitchSchemas({ }); ``` -### Injected representations +Stubbed types are easy to setup and effectively work as automated [schema extensions](/docs/stitch-schema-extensions) (in fact, you might not need extensions!). A stubbed type may always be enhanced with additional service-specific fields (like in the [basic example](#basic-example)), however it will require a query in `merge` config as soon as it offers unique data. + +In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stubbed type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of an object with a single delegation per external service. + +### Injected keys + +Until now we've only been including a `User` concept in the listing service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving an entire object tree per service with a single delegation to each. Here's an complete example: ```js const listings = [ @@ -290,24 +303,22 @@ const usersSchema = makeExecutableSchema({ } type Listing { - id: ID! seller: User! buyer: User } input ListingRepresentation { - id: ID! sellerId: ID buyerId: ID } type Query { - _listingsByRepresentations(representations: [ListingRepresentation!]!): [Listing]! + _listingsByReps(representations: [ListingRepresentation!]!): [Listing]! } `, resolvers: { Query: { - _listingsByRepresentations: (obj, args) => args.representations, + _listingsByReps: (obj, args) => args.representations, }, Listing: { seller(listing) { @@ -319,7 +330,17 @@ const usersSchema = makeExecutableSchema({ } } }); +``` + +Some important concerns to notice in the above schema: + +- Listings service `Listing` now provides `buyerId` and `sellerId` keys rather than direct associations. +- Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id` concept. +- Users service defines a `ListingRepresentation` input for external keys, and provides a `_listingsByReps` query that recieves them. +To bring this pattern together, the gateway plays a crucial orchestration role by collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as fully-rendered partial types: + +```js const gatewaySchema = stitchSchemas({ subschemas: [ { @@ -336,9 +357,9 @@ const gatewaySchema = stitchSchemas({ schema: usersSchema, merge: { Listing: { - selectionSet: '{ id sellerId buyerId }', - fieldName: '_listingsByRepresentations', - key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + selectionSet: '{ sellerId buyerId }', + fieldName: '_listingsByReps', + key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), args: (representations) => ({ representations }), } } @@ -348,24 +369,46 @@ const gatewaySchema = stitchSchemas({ }); ``` +Now, you may notice that both `buyerId` and `sellerId` keys are _always_ requested from the listing service, even though they are both really only needed when resolving their respective association field. If we were sensitive to costs associated with keys, then we could be judicious about only selecting them when needed with a field-level selection set mapping: + ```js -const usersSubschema = { +{ schema: usersSchema, merge: { Listing: { - selectionSet: '{ id }', fields: { seller: { selectionSet: '{ sellerId }' }, buyer: { selectionSet: '{ buyerId }' }, }, - fieldName: '_listingsByRepresentations', - key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + fieldName: '_listingsByReps', + key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), + args: (representations) => ({ representations }), + } + } +} +``` + +### Federation services + +If you're familiar with [Apollo Federation](), then you may notice that the above pattern of injected keys looks familiar... You're right, it's very similar to the `_entities` service design of the [Federation schema specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). + +In fact, type merging can seamlessly interface with Federation services by sending appropraitely formatted representations to their `_entities` query: + +```js +{ + schema: usersSchema, + merge: { + Listing: { + selectionSet: '{ sellerId buyerId }', + fieldName: '_entities', + key: ({ sellerId, buyerId }) => ({ sellerId, buyerId, __typename: 'Listing' }), args: (representations) => ({ representations }), } } } ``` +## Custom type resolvers The `merge` property on the `SubschemaConfig` object determines how types are merged, and is a map of `MergedTypeConfig` objects: From dac4336da7f8e01f0c821197d988e04220661c99 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 11:18:00 -0400 Subject: [PATCH 03/13] [deploy_website] type merging docs. --- website/docs/stitch-schema-extensions.md | 4 +- ...erging-types.md => stitch-type-merging.md} | 138 +++++++++--------- website/sidebars.json | 2 +- 3 files changed, 75 insertions(+), 69 deletions(-) rename website/docs/{stitch-merging-types.md => stitch-type-merging.md} (57%) diff --git a/website/docs/stitch-schema-extensions.md b/website/docs/stitch-schema-extensions.md index a65ac5cbb6c..a8795cd85b9 100644 --- a/website/docs/stitch-schema-extensions.md +++ b/website/docs/stitch-schema-extensions.md @@ -4,7 +4,9 @@ title: Extending stitched schemas sidebar_label: Schema extensions --- -While stitching many schemas together is extremely useful for consolidating queries, in practice we'll often want to add additional association fields that connect types to one another across subschemas. Using schema extensions, we can define additional GraphQL fields that only exist in the combined gateway schema to establish these connections. +While stitching many schemas together is extremely useful for consolidating queries, in practice we'll often want to add additional association fields that connect types to one another across subschemas. Using schema extensions, we can define additional GraphQL fields that only exist in the combined gateway schema that establish these connections. + +While considering these capabilities, be sure to compare them with the newer automated features available through [type merging](/docs/stitch-type-merging). ## Basic example diff --git a/website/docs/stitch-merging-types.md b/website/docs/stitch-type-merging.md similarity index 57% rename from website/docs/stitch-merging-types.md rename to website/docs/stitch-type-merging.md index c568e6a604d..6c0db045370 100644 --- a/website/docs/stitch-merging-types.md +++ b/website/docs/stitch-type-merging.md @@ -1,35 +1,21 @@ --- -id: stitch-merging-types -title: Type Merging -sidebar_label: Type Merging +id: stitch-type-merging +title: Type merging +sidebar_label: Type merging --- -Type merging offers an alternative strategy to [schema extensions](/docs/stitch-schema-extensions) for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. +Type merging offers an alternative strategy to [schema extensions](/docs/stitch-schema-extensions) for including types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. Using type merging frequently eliminates the need for schema extensions, though does not preclude their use. Merging can often outperform extensions by resolving entire portions of an object tree with a single delegation. More broadly, it offers similar capabilities to [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) while using only plain GraphQL and bare-metal configuration. ## Basic example -Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may post listings for sale to other users. Separating listings from users might look like this: +Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may list items for sale that other users can purchase. Separating listings from users might look like this: ```js import { makeExecutableSchema } from '@graphql-tools/schema'; import { addMocksToSchema } from '@graphql-tools/mock'; -let usersSchema = makeExecutableSchema({ - typeDefs: ` - type User { - id: ID! - username: String! - email: String! - } - - type Query { - userById(id: ID!): User - } - ` -}); - let listingsSchema = makeExecutableSchema({ typeDefs: ` type Listing { @@ -52,12 +38,25 @@ let listingsSchema = makeExecutableSchema({ ` }); +let usersSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + email: String! + } + + type Query { + userById(id: ID!): User + } + ` +}); + // just mock the schemas for now to make them return dummy data -usersSchema = addMocksToSchema({ schema: usersSchema }); listingsSchema = addMocksToSchema({ schema: listingsSchema }); +usersSchema = addMocksToSchema({ schema: usersSchema }); ``` -Note that both services define a `User` type. While the users service manages information about the user account, the listings service appropraitely provides listings associated with the user ID. Now we just have to configure the `User` type to be merged: +Note that both services define a _different_ `User` type. While the users service manages information about user accounts, the listings service simply provides listings associated with a user ID. Now we just have to configure the `User` type to be merged: ```js import { stitchSchemas } from '@graphql-tools/stitch'; @@ -65,22 +64,22 @@ import { stitchSchemas } from '@graphql-tools/stitch'; const gatewaySchema = stitchSchemas({ subschemas: [ { - schema: usersSchema, + schema: listingsSchema, merge: { User: { fieldName: 'userById', selectionSet: '{ id }', - args: (userEntity) => ({ id: userEntity.id }), + args: (partialUser) => ({ id: partialUser.id }), } } }, { - schema: listingsSchema, + schema: usersSchema, merge: { User: { fieldName: 'userById', selectionSet: '{ id }', - args: (userEntity) => ({ id: userEntity.id }), + args: (partialUser) => ({ id: partialUser.id }), } } }, @@ -89,14 +88,13 @@ const gatewaySchema = stitchSchemas({ }); ``` -That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type _entity_, or—partial type (services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly resolve a complete `User`, regardless of which service provides the initial entity. +That's it! Under the [subschema config](/docs/stitch-combining-schemas#subschema-configs) `merge` option, each subschema simply provides a query for accessing its respective partial type (services without an expression of the type may omit this). The merge config's `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from other services to perform the query, and `args` formats the preceding partial data into query arguments. This configuration allows type merging to smartly resolve a complete `User`, regardless of which service provides the initial representation of it. -The `User` schema is now structured like this in the gateway: +We now have a combined `User` type is in the gateway schema! ```graphql type User { id: ID! - username: String! email: String! listings: [Listing]! } @@ -104,13 +102,13 @@ type User { ### With batching -A big inefficiency in the example above is that subschemas are queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving arrays of objects. This inefficiency can be avoided by turning our single-record queries into array queries that facillitate batching: +An inefficiency in the example above is that subschemas are queried for only one `User` partial at a time via `userById`. These single queries quickly add up, especially when resolving arrays of objects. We can fix this with batching. The first thing we'll need are array queries that fetch many partials at once from each service: ```graphql usersByIds(ids: [ID!]!): [User]! ``` -Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then transforms the list of picked keys into query arguments: +Once each service provides an array query, batching may be enabled by adding a `key` method that picks a key from each partial record. The `argsFromKeys` method then transforms the list of picked keys into query arguments: ```js const gatewaySchema = stitchSchemas({ @@ -122,7 +120,7 @@ const gatewaySchema = stitchSchemas({ fieldName: 'usersByIds', selectionSet: '{ id }', key: ({ id }) => id, - args: (ids) => ({ ids }), + argsFromKeys: (ids) => ({ ids }), } } }, @@ -133,7 +131,7 @@ const gatewaySchema = stitchSchemas({ fieldName: 'usersByIds', selectionSet: '{ id }', key: ({ id }) => id, - args: (ids) => ({ ids }), + argsFromKeys: (ids) => ({ ids }), } } }, @@ -142,9 +140,11 @@ const gatewaySchema = stitchSchemas({ }); ``` -### Partial types without a database +A `valuesFromResults` method may also be provided to map the raw query result into the batched set. + +### Types without a database -It's easy to imagine that each `usersByIds` query has a backing database table used to lookup the requested users. However, this is frequently not the case. For example, here's a simple resolver implementation that demonstrates how `User.listings` could be resolved without the listing service having any database concept of a User: +It's logical to assume that each `usersByIds` query has a backing database table used to lookup the requested user IDs. However, this is frequently not the case! Here's a simple example that demonstrates how `User.listings` can be resolved without the listings service having any formal database concept of a User: ```js const listingsData = [ @@ -186,14 +186,15 @@ const listingsSchema = makeExecutableSchema({ }); ``` -In this example, `usersByIds` simply converts the submitted IDs into stub records that get resolved as the `User` type. This pattern can be expanded even futher using [injected entities](). +In this example, `usersByIds` simply converts the submitted IDs into stub records that get resolved as the local `User` type. This can be expanded even futher using a formal [pattern of injected keys](/docs/stitch-type-merging#injected-keys). ## Merging patterns +There are many ways to structure type merging, and none of them are wrong! The best ways depend on what makes sense in your schema. Here are some common merging patterns that can be mixed and matched... ### Stub types -The simplest pattern for providing a type across subschemas is to simply include an ID-only stub representing it in any schema that uses it, and allow for external data to be merged onto the stub. For example: +The simplest pattern for providing a type across subschemas is to simply include an ID-only stub representing it where needed, and allow for external data to be merged onto the stub. For example: ```js let listingsSchema = makeExecutableSchema({ @@ -221,7 +222,7 @@ let usersSchema = makeExecutableSchema({ typeDefs: ` type User { id: ID! - username: String! + email: String! } type Query { @@ -231,7 +232,7 @@ let usersSchema = makeExecutableSchema({ }); ``` -When a stubbed type includes no other data beyond a shared key, then the type may be considered _unidirectional_ to the service—that is, the service holds no unique data that would require an inbound request to fetch it. In these cases, `merge` config may be omitted entirely for the stubbed type: +When a stubbed type includes no other data beyond a shared key, then the type may be considered _unidirectional_ to the service—that is, the service holds no unique data that would require an inbound request to fetch it. In these cases, `merge` config may be omitted entirely for the stub type: ```js const gatewaySchema = stitchSchemas({ @@ -246,7 +247,7 @@ const gatewaySchema = stitchSchemas({ selectionSet: '{ id }', fieldName: 'usersByIds', key: ({ id }) => id, - args: (ids) => ({ ids }), + argsFromKeys: (ids) => ({ ids }), } } }, @@ -255,13 +256,13 @@ const gatewaySchema = stitchSchemas({ }); ``` -Stubbed types are easy to setup and effectively work as automated [schema extensions](/docs/stitch-schema-extensions) (in fact, you might not need extensions!). A stubbed type may always be enhanced with additional service-specific fields (like in the [basic example](#basic-example)), however it will require a query in `merge` config as soon as it offers unique data. +Stubbed types are easy to setup and effectively work as automatic [schema extensions](/docs/stitch-schema-extensions) (in fact, you might not need extensions!). A stubbed type may always be expanded with additional service-specific fields (see the [basic example](#basic-example)), however it requires a query in `merge` config as soon as it offers unique data. -In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stubbed type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of an object with a single delegation per external service. +In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stub type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of a type with a single delegation per external service. ### Injected keys -Until now we've only been including a `User` concept in the listing service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving an entire object tree per service with a single delegation to each. Here's an complete example: +Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving entire partial types with a single delegation per service. Here's a complete example: ```js const listings = [ @@ -291,15 +292,15 @@ const listingsSchema = makeExecutableSchema({ }); const users = [ - { id: '1', username: 'bigvader23' }, - { id: '2', username: 'hanshotfirst' }, + { id: '1', email: 'bigvader23@empire.me' }, + { id: '2', email: 'hanshotfirst@solo.net' }, ]; const usersSchema = makeExecutableSchema({ typeDefs: ` type User { id: ID! - username: String! + email: String! } type Listing { @@ -332,13 +333,13 @@ const usersSchema = makeExecutableSchema({ }); ``` -Some important concerns to notice in the above schema: +Some important features to notice in the above schema: -- Listings service `Listing` now provides `buyerId` and `sellerId` keys rather than direct associations. -- Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id` concept. -- Users service defines a `ListingRepresentation` input for external keys, and provides a `_listingsByReps` query that recieves them. +- Listings service `Listing` now provides `buyerId` and `sellerId` keys rather than direct user associations. +- Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id`. +- Users service defines a `ListingRepresentation` input for external keys, and a `_listingsByReps` query that recieves them. -To bring this pattern together, the gateway plays a crucial orchestration role by collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as fully-rendered partial types: +To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as complete partial types: ```js const gatewaySchema = stitchSchemas({ @@ -349,7 +350,8 @@ const gatewaySchema = stitchSchemas({ Listing: { selectionSet: '{ id }', fieldName: 'listingsByIds', - args: (obj) => ({ id: obj.id }), + key: ({ id }) => id, + argsFromKeys: (obj) => ({ id: obj.id }), } } }, @@ -360,7 +362,7 @@ const gatewaySchema = stitchSchemas({ selectionSet: '{ sellerId buyerId }', fieldName: '_listingsByReps', key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), - args: (representations) => ({ representations }), + argsFromKeys: (representations) => ({ representations }), } } }, @@ -369,7 +371,7 @@ const gatewaySchema = stitchSchemas({ }); ``` -Now, you may notice that both `buyerId` and `sellerId` keys are _always_ requested from the listing service, even though they are both really only needed when resolving their respective association field. If we were sensitive to costs associated with keys, then we could be judicious about only selecting them when needed with a field-level selection set mapping: +Now, you may notice that both `sellerId` and `buyerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective association fields. If we were sensitive to costs associated with keys, then we could judiciously select only what we need with a field-level selectionSet mapping: ```js { @@ -382,15 +384,17 @@ Now, you may notice that both `buyerId` and `sellerId` keys are _always_ request }, fieldName: '_listingsByReps', key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), - args: (representations) => ({ representations }), + argsFromKeys: (representations) => ({ representations }), } } } ``` +One minor disadvantage of this pattern is that the listings service includes ugly `sellerId` and `buyerId` fields. There's no harm in marking these IDs as `@deprecated`, or they may be removed completely from the gateway schema using a [transform](/docs/stitch-combining-schemas#adding-transforms). + ### Federation services -If you're familiar with [Apollo Federation](), then you may notice that the above pattern of injected keys looks familiar... You're right, it's very similar to the `_entities` service design of the [Federation schema specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). +If you're familiar with [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/), then you may notice that the above pattern of injected keys looks familiar... You're right, it's very similar to the `_entities` service design of the [Federation schema specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). In fact, type merging can seamlessly interface with Federation services by sending appropraitely formatted representations to their `_entities` query: @@ -402,15 +406,15 @@ In fact, type merging can seamlessly interface with Federation services by sendi selectionSet: '{ sellerId buyerId }', fieldName: '_entities', key: ({ sellerId, buyerId }) => ({ sellerId, buyerId, __typename: 'Listing' }), - args: (representations) => ({ representations }), + argsFromKeys: (representations) => ({ representations }), } } } ``` -## Custom type resolvers +## Custom merge resolvers -The `merge` property on the `SubschemaConfig` object determines how types are merged, and is a map of `MergedTypeConfig` objects: +The `merge` property of [subschema config](/docs/stitch-combining-schemas#subschema-configs) specifies how types are merged for a service, and provides a map of `MergedTypeConfig` objects: ```ts export interface MergedTypeConfig { @@ -422,9 +426,13 @@ export interface MergedTypeConfig { argsFromKeys?: (keys: ReadonlyArray) => Record; valuesFromResults?: (results: any, keys: ReadonlyArray) => Array; } +``` + +All merged types across subschemas will delegate as necessary to other subschemas implementing the same type using the provided `resolve` function of type `MergedTypeResolver`: +```ts export type MergedTypeResolver = ( - originalResult: any, // initial final result from a subschema + originalResult: any, // initial result from a previous subschema context: Record, // gateway context info: GraphQLResolveInfo, // gateway info subschema: GraphQLSchema | SubschemaConfig, // the additional implementing subschema from which to retrieve data @@ -432,13 +440,7 @@ export type MergedTypeResolver = ( ) => any; ``` -Type merging simply merges types with the same name, but is smart enough to apply the passed subschema transforms prior to merging types, so the types have to be identical on the gateway, not the individual subschema. - -All merged types returned by any subschema will delegate as necessary to subschemas also implementing the type, using the provided `resolve` function of type `MergedTypeResolver`. - -You can also use batch delegation instead of simple delegation by delegating to a root field returning a list and using the `key`, `argsFromKeys`, and `valuesFromResults` properties. See the [batch delegation](#batch-delegation) for more details. - -The simplified magic above happens because if left unspecified, we provide a default type-merging resolver for you, which uses the other `MergedTypeConfig` options (for simple delegation), as follows: +The default `resolve` implementation that powers type merging out of the box looks like this: ```js mergedTypeConfig.resolve = (originalResult, context, info, schemaOrSubschemaConfig, selectionSet) => @@ -455,7 +457,9 @@ mergedTypeConfig.resolve = (originalResult, context, info, schemaOrSubschemaConf }); ``` -When providing your own type-merging resolver, note the very important `skipTypeMerging` setting. Without this option, your gateway will keep busy merging types forever, as each result returned from each subschema will trigger another round of delegation to the other implementing subschemas! +This resolver switches to a batched implementation in the presence of a `mergedTypeConfig.key` function. You may also provide your own custom implementation, however... note the extremely important `skipTypeMerging` setting. Without this option, your gateway will recursively merge types forever! + +Type merging simply merges types of the same name, though it is smart enough to apply provided subschema transforms prior to merging. That means types have to be identical on the gateway, but not the individual subschema. Finally, you may wish to fine-tune which types are merged. Besides taking a boolean value, you can also specify an array of type names, or a function of type `MergeTypeFilter` that takes the potential types and decides dynamically how to merge. diff --git a/website/sidebars.json b/website/sidebars.json index 9f2b8922ad4..c26c3767145 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -25,7 +25,7 @@ "Schema stitching": [ "stitch-combining-schemas", "stitch-schema-extensions", - "stitch-merging-types", + "stitch-type-merging", "stitch-api" ] }, From 44ec7077b479340cb29f570e802b6d418fa72029 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Thu, 13 Aug 2020 01:05:42 -0400 Subject: [PATCH 04/13] initial example. --- website/docs/stitch-merging-types.md | 348 ++++++++++++++++++++++++--- 1 file changed, 317 insertions(+), 31 deletions(-) diff --git a/website/docs/stitch-merging-types.md b/website/docs/stitch-merging-types.md index fc2de78bfae..1e7ce730b1c 100644 --- a/website/docs/stitch-merging-types.md +++ b/website/docs/stitch-merging-types.md @@ -1,86 +1,372 @@ --- id: stitch-merging-types -title: Merging types -sidebar_label: Merging types +title: Type Merging +sidebar_label: Type Merging --- -## Merging types +Type merging offers an alternative strategy to [schema extensions]() for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. -We are still stuck exposing the `authorId` field within the `Chirp` type even though we actually just want to include an `author`. We can always use transforms to filter out the `authorId` field, but it would be nice if this could be made more ergonomical. It also makes stitching somewhat more difficult when the authorId is not directly exposed -- for example, in a situation where we do not control the remote subschema in question. +Using type merging frequently eliminates the need for schema extensions, though does not preclude their use. Merging can often outperform extensions by resolving entire portions of an object tree with a single delegation. More broadly, it offers similar capabilities to [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) while using only plain GraphQL and bare-metal configuration. -What is the chirps subschema also had its own self-contained user concept, and we just wanted to combine the relevant fields from both subschemas? +### Basic example + +Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may post listings for sale to other users. Separating listings from users might look like this: ```js -let chirpSchema = makeExecutableSchema({ - typeDefs: ` - type Chirp { - id: ID! - text: String - author: User - } +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { addMocksToSchema } from '@graphql-tools/mock'; +let usersSchema = makeExecutableSchema({ + typeDefs: ` type User { id: ID! - chirps: [Chirp] + username: String! + email: String! } type Query { - chirpById(id: ID!): Chirp - chirpsByUserId(id: ID!): [Chirp] userById(id: ID!): User } ` }); -chirpSchema = addMocksToSchema({ schema: chirpSchema }); - -// Mocked author schema -let authorSchema = makeExecutableSchema({ +let listingsSchema = makeExecutableSchema({ typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + seller: User! + buyer: User + } + type User { id: ID! - email: String + listings: [Listing]! } type Query { + listingById(id: ID!): Listing userById(id: ID!): User } ` }); -authorSchema = addMocksToSchema({ schema: authorSchema }); +// just mock the schemas for now to make them return dummy data +usersSchema = addMocksToSchema({ schema: usersSchema }); +listingsSchema = addMocksToSchema({ schema: listingsSchema }); ``` -This can now be accomplished by turning on type merging! +Note that both services define a `User` type. While the users service manages information about the user account, the listings service appropraitely provides listings associated with the user ID. Now we just have to configure the `User` type to be merged: ```js -const stitchedSchema = stitchSchemas({ +import { stitchSchemas } from '@graphql-tools/stitch'; + +const gatewaySchema = stitchSchemas({ subschemas: [ { - schema: chirpSchema, + schema: usersSchema, merge: { User: { fieldName: 'userById', - args: (originalResult) => ({ id: originalResult.id }), selectionSet: '{ id }', - }, - }, + args: (userEntity) => ({ id: userEntity.id }), + } + } }, { - schema: authorSchema, + schema: listingsSchema, merge: { User: { fieldName: 'userById', - args: (originalResult) => ({ id: originalResult.id }), selectionSet: '{ id }', - }, + args: (userEntity) => ({ id: userEntity.id }), + } + } + }, + ], + mergeTypes: true +}); +``` + +That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type entity (i.e.: version of the type—services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly query a complete `User`, regardless of which service provides the initial entity. + +The `User` schema now looks like this in the gateway: + +```graphql +type User { + id: ID! + username: String! + email: String! + listings: [Listing]! +} +``` + +#### With batching + +One big problem in the example above is that subschemas are being queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving lists. This can be avoided by setting up array queries for batching: + +```graphql +usersByIds(ids: [ID!]!): [User]! +``` + +Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then translates the list of keys into query arguments: + +```js +const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: listingsSchema, + merge: { + User: { + fieldName: 'usersByIds', + selectionSet: '{ id }', + key: ({ id }) => id, + args: (ids) => ({ ids }), + } + } + }, + { + schema: usersSchema, + merge: { + User: { + fieldName: 'usersByIds', + selectionSet: '{ id }', + key: ({ id }) => id, + args: (ids) => ({ ids }), + } + } + }, + ], + mergeTypes: true +}); +``` + +#### Services without a database + +```js +const listings = [ + { id: '1', description: 'Junk for sale', price: 10.99, sellerId: '1', buyerId: '2' }, + { id: '2', description: 'Spare parts', price: 200.99, sellerId: '1', buyerId: null }, +]; + +const listingsSchema = makeExecutableSchema({ + typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + seller: User! + buyer: User + } + + type User { + id: ID! + listings: [Listing]! + } + + type Query { + listingsByIds(ids: [ID!]!): [Listing]! + usersByIds(ids: [ID!]!): [User]! + } + `, + resolvers: { + Query: { + listingsByIds: (root, args) => args.ids.map(id => listings.find(listing => listing.id === id)), + usersByIds: (root, args) => args.ids.map(id => { id }), + }, + User: { + listings(user) { + return listings.filter(listing => listing.sellerId === user.id); + } + } + } +}); +``` + +### Unidrectional merging + +```js +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { addMocksToSchema } from '@graphql-tools/mock'; +import { stitchSchemas } from '@graphql-tools/stitch'; + +let listingsSchema = makeExecutableSchema({ + typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + seller: User! + buyer: User + } + + type User { + id: ID! + } + + type Query { + listingById(id: ID!): Listing + } + ` +}); + +let usersSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String! + } + + type Query { + usersByIds(ids: [ID!]!): [User]! + } + ` +}); + +listingsSchema = addMocksToSchema({ schema: listingsSchema }); +usersSchema = addMocksToSchema({ schema: usersSchema }); + +const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: listingsSchema, + }, + { + schema: usersSchema, + merge: { + User: { + selectionSet: '{ id }', + fieldName: 'usersByIds', + key: ({ id }) => id, + args: (ids) => ({ ids }), + } + } + }, + ], + mergeTypes: true +}); +``` + +### Injected representations + +```js +const listings = [ + { id: '1', description: 'Junk for sale', price: 10.99, sellerId: '1', buyerId: '2' }, + { id: '2', description: 'Spare parts', price: 200.99, sellerId: '1', buyerId: null }, +]; + +const listingsSchema = makeExecutableSchema({ + typeDefs: ` + type Listing { + id: ID! + description: String! + price: Float! + sellerId: ID! + buyerId: ID + } + + type Query { + listingsByIds(ids: [ID!]!): [Listing]! + } + `, + resolvers: { + Query: { + listingsByIds: (root, args) => args.ids.map(id => listings.find(listing => listing.id === id)), + } + } +}); + +const users = [ + { id: '1', username: 'bigvader23' }, + { id: '2', username: 'hanshotfirst' }, +]; + +const usersSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String! + } + + type Listing { + id: ID! + seller: User! + buyer: User + } + + input ListingRepresentation { + id: ID! + sellerId: ID + buyerId: ID + } + + type Query { + _listingsByRepresentations(representations: [ListingRepresentation!]!): [Listing]! + } + `, + resolvers: { + Query: { + _listingsByRepresentations: (obj, args) => args.representations, + }, + Listing: { + seller(listing) { + return users.find(user => user.id === listing.sellerId); }, + buyer(listing) { + return users.find(user => user.id === listing.buyerId) || null; + } + } + } +}); + +const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: listingsSchema, + merge: { + Listing: { + selectionSet: '{ id }', + fieldName: 'listingsByIds', + args: (obj) => ({ id: obj.id }), + } + } + }, + { + schema: usersSchema, + merge: { + Listing: { + selectionSet: '{ id sellerId buyerId }', + fieldName: '_listingsByRepresentations', + key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + args: (representations) => ({ representations }), + } + } }, ], - mergeTypes: true, + mergeTypes: true }); ``` +```js +const usersSubschema = { + schema: usersSchema, + merge: { + Listing: { + selectionSet: '{ id }', + fields: { + seller: { selectionSet: '{ sellerId }' }, + buyer: { selectionSet: '{ buyerId }' }, + }, + fieldName: '_listingsByRepresentations', + key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + args: (representations) => ({ representations }), + } + } +} +``` + + The `merge` property on the `SubschemaConfig` object determines how types are merged, and is a map of `MergedTypeConfig` objects: ```ts From bb330943a6ff04a41886bacb9fb0c3640e4b3a80 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 00:28:14 -0400 Subject: [PATCH 05/13] add patterns writeup. --- website/docs/stitch-merging-types.md | 103 +++++++++++++++++++-------- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/website/docs/stitch-merging-types.md b/website/docs/stitch-merging-types.md index 1e7ce730b1c..c568e6a604d 100644 --- a/website/docs/stitch-merging-types.md +++ b/website/docs/stitch-merging-types.md @@ -4,11 +4,11 @@ title: Type Merging sidebar_label: Type Merging --- -Type merging offers an alternative strategy to [schema extensions]() for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. +Type merging offers an alternative strategy to [schema extensions](/docs/stitch-schema-extensions) for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. Using type merging frequently eliminates the need for schema extensions, though does not preclude their use. Merging can often outperform extensions by resolving entire portions of an object tree with a single delegation. More broadly, it offers similar capabilities to [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) while using only plain GraphQL and bare-metal configuration. -### Basic example +## Basic example Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may post listings for sale to other users. Separating listings from users might look like this: @@ -89,9 +89,9 @@ const gatewaySchema = stitchSchemas({ }); ``` -That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type entity (i.e.: version of the type—services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly query a complete `User`, regardless of which service provides the initial entity. +That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type _entity_, or—partial type (services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly resolve a complete `User`, regardless of which service provides the initial entity. -The `User` schema now looks like this in the gateway: +The `User` schema is now structured like this in the gateway: ```graphql type User { @@ -102,15 +102,15 @@ type User { } ``` -#### With batching +### With batching -One big problem in the example above is that subschemas are being queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving lists. This can be avoided by setting up array queries for batching: +A big inefficiency in the example above is that subschemas are queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving arrays of objects. This inefficiency can be avoided by turning our single-record queries into array queries that facillitate batching: ```graphql usersByIds(ids: [ID!]!): [User]! ``` -Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then translates the list of keys into query arguments: +Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then transforms the list of picked keys into query arguments: ```js const gatewaySchema = stitchSchemas({ @@ -142,10 +142,12 @@ const gatewaySchema = stitchSchemas({ }); ``` -#### Services without a database +### Partial types without a database + +It's easy to imagine that each `usersByIds` query has a backing database table used to lookup the requested users. However, this is frequently not the case. For example, here's a simple resolver implementation that demonstrates how `User.listings` could be resolved without the listing service having any database concept of a User: ```js -const listings = [ +const listingsData = [ { id: '1', description: 'Junk for sale', price: 10.99, sellerId: '1', buyerId: '2' }, { id: '2', description: 'Spare parts', price: 200.99, sellerId: '1', buyerId: null }, ]; @@ -172,25 +174,28 @@ const listingsSchema = makeExecutableSchema({ `, resolvers: { Query: { - listingsByIds: (root, args) => args.ids.map(id => listings.find(listing => listing.id === id)), + listingsByIds: (root, args) => args.ids.map(id => listingsData.find(listing => listing.id === id)), usersByIds: (root, args) => args.ids.map(id => { id }), }, User: { listings(user) { - return listings.filter(listing => listing.sellerId === user.id); + return listingsData.filter(listing => listing.sellerId === user.id); } } } }); ``` -### Unidrectional merging +In this example, `usersByIds` simply converts the submitted IDs into stub records that get resolved as the `User` type. This pattern can be expanded even futher using [injected entities](). -```js -import { makeExecutableSchema } from '@graphql-tools/schema'; -import { addMocksToSchema } from '@graphql-tools/mock'; -import { stitchSchemas } from '@graphql-tools/stitch'; +## Merging patterns + + +### Stub types + +The simplest pattern for providing a type across subschemas is to simply include an ID-only stub representing it in any schema that uses it, and allow for external data to be merged onto the stub. For example: +```js let listingsSchema = makeExecutableSchema({ typeDefs: ` type Listing { @@ -201,6 +206,7 @@ let listingsSchema = makeExecutableSchema({ buyer: User } + # stubbed type... type User { id: ID! } @@ -223,10 +229,11 @@ let usersSchema = makeExecutableSchema({ } ` }); +``` -listingsSchema = addMocksToSchema({ schema: listingsSchema }); -usersSchema = addMocksToSchema({ schema: usersSchema }); +When a stubbed type includes no other data beyond a shared key, then the type may be considered _unidirectional_ to the service—that is, the service holds no unique data that would require an inbound request to fetch it. In these cases, `merge` config may be omitted entirely for the stubbed type: +```js const gatewaySchema = stitchSchemas({ subschemas: [ { @@ -248,7 +255,13 @@ const gatewaySchema = stitchSchemas({ }); ``` -### Injected representations +Stubbed types are easy to setup and effectively work as automated [schema extensions](/docs/stitch-schema-extensions) (in fact, you might not need extensions!). A stubbed type may always be enhanced with additional service-specific fields (like in the [basic example](#basic-example)), however it will require a query in `merge` config as soon as it offers unique data. + +In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stubbed type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of an object with a single delegation per external service. + +### Injected keys + +Until now we've only been including a `User` concept in the listing service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving an entire object tree per service with a single delegation to each. Here's an complete example: ```js const listings = [ @@ -290,24 +303,22 @@ const usersSchema = makeExecutableSchema({ } type Listing { - id: ID! seller: User! buyer: User } input ListingRepresentation { - id: ID! sellerId: ID buyerId: ID } type Query { - _listingsByRepresentations(representations: [ListingRepresentation!]!): [Listing]! + _listingsByReps(representations: [ListingRepresentation!]!): [Listing]! } `, resolvers: { Query: { - _listingsByRepresentations: (obj, args) => args.representations, + _listingsByReps: (obj, args) => args.representations, }, Listing: { seller(listing) { @@ -319,7 +330,17 @@ const usersSchema = makeExecutableSchema({ } } }); +``` + +Some important concerns to notice in the above schema: + +- Listings service `Listing` now provides `buyerId` and `sellerId` keys rather than direct associations. +- Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id` concept. +- Users service defines a `ListingRepresentation` input for external keys, and provides a `_listingsByReps` query that recieves them. +To bring this pattern together, the gateway plays a crucial orchestration role by collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as fully-rendered partial types: + +```js const gatewaySchema = stitchSchemas({ subschemas: [ { @@ -336,9 +357,9 @@ const gatewaySchema = stitchSchemas({ schema: usersSchema, merge: { Listing: { - selectionSet: '{ id sellerId buyerId }', - fieldName: '_listingsByRepresentations', - key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + selectionSet: '{ sellerId buyerId }', + fieldName: '_listingsByReps', + key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), args: (representations) => ({ representations }), } } @@ -348,24 +369,46 @@ const gatewaySchema = stitchSchemas({ }); ``` +Now, you may notice that both `buyerId` and `sellerId` keys are _always_ requested from the listing service, even though they are both really only needed when resolving their respective association field. If we were sensitive to costs associated with keys, then we could be judicious about only selecting them when needed with a field-level selection set mapping: + ```js -const usersSubschema = { +{ schema: usersSchema, merge: { Listing: { - selectionSet: '{ id }', fields: { seller: { selectionSet: '{ sellerId }' }, buyer: { selectionSet: '{ buyerId }' }, }, - fieldName: '_listingsByRepresentations', - key: ({ id, sellerId, buyerId }) => ({ id, sellerId, buyerId }), + fieldName: '_listingsByReps', + key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), + args: (representations) => ({ representations }), + } + } +} +``` + +### Federation services + +If you're familiar with [Apollo Federation](), then you may notice that the above pattern of injected keys looks familiar... You're right, it's very similar to the `_entities` service design of the [Federation schema specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). + +In fact, type merging can seamlessly interface with Federation services by sending appropraitely formatted representations to their `_entities` query: + +```js +{ + schema: usersSchema, + merge: { + Listing: { + selectionSet: '{ sellerId buyerId }', + fieldName: '_entities', + key: ({ sellerId, buyerId }) => ({ sellerId, buyerId, __typename: 'Listing' }), args: (representations) => ({ representations }), } } } ``` +## Custom type resolvers The `merge` property on the `SubschemaConfig` object determines how types are merged, and is a map of `MergedTypeConfig` objects: From a2b5dd7f90178bd67755850a49669d74bc644618 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 11:18:00 -0400 Subject: [PATCH 06/13] [deploy_website] type merging docs. --- website/docs/stitch-schema-extensions.md | 4 +- ...erging-types.md => stitch-type-merging.md} | 138 +++++++++--------- website/sidebars.json | 2 +- 3 files changed, 75 insertions(+), 69 deletions(-) rename website/docs/{stitch-merging-types.md => stitch-type-merging.md} (57%) diff --git a/website/docs/stitch-schema-extensions.md b/website/docs/stitch-schema-extensions.md index a65ac5cbb6c..a8795cd85b9 100644 --- a/website/docs/stitch-schema-extensions.md +++ b/website/docs/stitch-schema-extensions.md @@ -4,7 +4,9 @@ title: Extending stitched schemas sidebar_label: Schema extensions --- -While stitching many schemas together is extremely useful for consolidating queries, in practice we'll often want to add additional association fields that connect types to one another across subschemas. Using schema extensions, we can define additional GraphQL fields that only exist in the combined gateway schema to establish these connections. +While stitching many schemas together is extremely useful for consolidating queries, in practice we'll often want to add additional association fields that connect types to one another across subschemas. Using schema extensions, we can define additional GraphQL fields that only exist in the combined gateway schema that establish these connections. + +While considering these capabilities, be sure to compare them with the newer automated features available through [type merging](/docs/stitch-type-merging). ## Basic example diff --git a/website/docs/stitch-merging-types.md b/website/docs/stitch-type-merging.md similarity index 57% rename from website/docs/stitch-merging-types.md rename to website/docs/stitch-type-merging.md index c568e6a604d..6c0db045370 100644 --- a/website/docs/stitch-merging-types.md +++ b/website/docs/stitch-type-merging.md @@ -1,35 +1,21 @@ --- -id: stitch-merging-types -title: Type Merging -sidebar_label: Type Merging +id: stitch-type-merging +title: Type merging +sidebar_label: Type merging --- -Type merging offers an alternative strategy to [schema extensions](/docs/stitch-schema-extensions) for bridging types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. +Type merging offers an alternative strategy to [schema extensions](/docs/stitch-schema-extensions) for including types across subschemas. It allows _partial definitions_ of a type to exist in any subschema, and then merges all partials into one unified type in the gateway schema. When querying for a merged type, the gateway smartly delegates portions of the request to each relevant subschema in dependency order, and then combines all results for the final return. Using type merging frequently eliminates the need for schema extensions, though does not preclude their use. Merging can often outperform extensions by resolving entire portions of an object tree with a single delegation. More broadly, it offers similar capabilities to [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) while using only plain GraphQL and bare-metal configuration. ## Basic example -Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may post listings for sale to other users. Separating listings from users might look like this: +Type merging encourages types to be split naturally across services by concern. For example, let's make a small classifieds app where users may list items for sale that other users can purchase. Separating listings from users might look like this: ```js import { makeExecutableSchema } from '@graphql-tools/schema'; import { addMocksToSchema } from '@graphql-tools/mock'; -let usersSchema = makeExecutableSchema({ - typeDefs: ` - type User { - id: ID! - username: String! - email: String! - } - - type Query { - userById(id: ID!): User - } - ` -}); - let listingsSchema = makeExecutableSchema({ typeDefs: ` type Listing { @@ -52,12 +38,25 @@ let listingsSchema = makeExecutableSchema({ ` }); +let usersSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + email: String! + } + + type Query { + userById(id: ID!): User + } + ` +}); + // just mock the schemas for now to make them return dummy data -usersSchema = addMocksToSchema({ schema: usersSchema }); listingsSchema = addMocksToSchema({ schema: listingsSchema }); +usersSchema = addMocksToSchema({ schema: usersSchema }); ``` -Note that both services define a `User` type. While the users service manages information about the user account, the listings service appropraitely provides listings associated with the user ID. Now we just have to configure the `User` type to be merged: +Note that both services define a _different_ `User` type. While the users service manages information about user accounts, the listings service simply provides listings associated with a user ID. Now we just have to configure the `User` type to be merged: ```js import { stitchSchemas } from '@graphql-tools/stitch'; @@ -65,22 +64,22 @@ import { stitchSchemas } from '@graphql-tools/stitch'; const gatewaySchema = stitchSchemas({ subschemas: [ { - schema: usersSchema, + schema: listingsSchema, merge: { User: { fieldName: 'userById', selectionSet: '{ id }', - args: (userEntity) => ({ id: userEntity.id }), + args: (partialUser) => ({ id: partialUser.id }), } } }, { - schema: listingsSchema, + schema: usersSchema, merge: { User: { fieldName: 'userById', selectionSet: '{ id }', - args: (userEntity) => ({ id: userEntity.id }), + args: (partialUser) => ({ id: partialUser.id }), } } }, @@ -89,14 +88,13 @@ const gatewaySchema = stitchSchemas({ }); ``` -That's it! When setting up merge config, each subschema simply provides a query for accessing its respective type _entity_, or—partial type (services without an expression of the type may omit this query). The `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from the initial entity, and `args` formats the initial entity into query arguments. This config allows type merging to smartly resolve a complete `User`, regardless of which service provides the initial entity. +That's it! Under the [subschema config](/docs/stitch-combining-schemas#subschema-configs) `merge` option, each subschema simply provides a query for accessing its respective partial type (services without an expression of the type may omit this). The merge config's `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from other services to perform the query, and `args` formats the preceding partial data into query arguments. This configuration allows type merging to smartly resolve a complete `User`, regardless of which service provides the initial representation of it. -The `User` schema is now structured like this in the gateway: +We now have a combined `User` type is in the gateway schema! ```graphql type User { id: ID! - username: String! email: String! listings: [Listing]! } @@ -104,13 +102,13 @@ type User { ### With batching -A big inefficiency in the example above is that subschemas are queried for one `User` entity at a time via `userById`. These single queries get expensive, especially when resolving arrays of objects. This inefficiency can be avoided by turning our single-record queries into array queries that facillitate batching: +An inefficiency in the example above is that subschemas are queried for only one `User` partial at a time via `userById`. These single queries quickly add up, especially when resolving arrays of objects. We can fix this with batching. The first thing we'll need are array queries that fetch many partials at once from each service: ```graphql usersByIds(ids: [ID!]!): [User]! ``` -Once each service provides an array query, batching may be enabled by adding a `key` method to pick a key from each entity. The `args` method then transforms the list of picked keys into query arguments: +Once each service provides an array query, batching may be enabled by adding a `key` method that picks a key from each partial record. The `argsFromKeys` method then transforms the list of picked keys into query arguments: ```js const gatewaySchema = stitchSchemas({ @@ -122,7 +120,7 @@ const gatewaySchema = stitchSchemas({ fieldName: 'usersByIds', selectionSet: '{ id }', key: ({ id }) => id, - args: (ids) => ({ ids }), + argsFromKeys: (ids) => ({ ids }), } } }, @@ -133,7 +131,7 @@ const gatewaySchema = stitchSchemas({ fieldName: 'usersByIds', selectionSet: '{ id }', key: ({ id }) => id, - args: (ids) => ({ ids }), + argsFromKeys: (ids) => ({ ids }), } } }, @@ -142,9 +140,11 @@ const gatewaySchema = stitchSchemas({ }); ``` -### Partial types without a database +A `valuesFromResults` method may also be provided to map the raw query result into the batched set. + +### Types without a database -It's easy to imagine that each `usersByIds` query has a backing database table used to lookup the requested users. However, this is frequently not the case. For example, here's a simple resolver implementation that demonstrates how `User.listings` could be resolved without the listing service having any database concept of a User: +It's logical to assume that each `usersByIds` query has a backing database table used to lookup the requested user IDs. However, this is frequently not the case! Here's a simple example that demonstrates how `User.listings` can be resolved without the listings service having any formal database concept of a User: ```js const listingsData = [ @@ -186,14 +186,15 @@ const listingsSchema = makeExecutableSchema({ }); ``` -In this example, `usersByIds` simply converts the submitted IDs into stub records that get resolved as the `User` type. This pattern can be expanded even futher using [injected entities](). +In this example, `usersByIds` simply converts the submitted IDs into stub records that get resolved as the local `User` type. This can be expanded even futher using a formal [pattern of injected keys](/docs/stitch-type-merging#injected-keys). ## Merging patterns +There are many ways to structure type merging, and none of them are wrong! The best ways depend on what makes sense in your schema. Here are some common merging patterns that can be mixed and matched... ### Stub types -The simplest pattern for providing a type across subschemas is to simply include an ID-only stub representing it in any schema that uses it, and allow for external data to be merged onto the stub. For example: +The simplest pattern for providing a type across subschemas is to simply include an ID-only stub representing it where needed, and allow for external data to be merged onto the stub. For example: ```js let listingsSchema = makeExecutableSchema({ @@ -221,7 +222,7 @@ let usersSchema = makeExecutableSchema({ typeDefs: ` type User { id: ID! - username: String! + email: String! } type Query { @@ -231,7 +232,7 @@ let usersSchema = makeExecutableSchema({ }); ``` -When a stubbed type includes no other data beyond a shared key, then the type may be considered _unidirectional_ to the service—that is, the service holds no unique data that would require an inbound request to fetch it. In these cases, `merge` config may be omitted entirely for the stubbed type: +When a stubbed type includes no other data beyond a shared key, then the type may be considered _unidirectional_ to the service—that is, the service holds no unique data that would require an inbound request to fetch it. In these cases, `merge` config may be omitted entirely for the stub type: ```js const gatewaySchema = stitchSchemas({ @@ -246,7 +247,7 @@ const gatewaySchema = stitchSchemas({ selectionSet: '{ id }', fieldName: 'usersByIds', key: ({ id }) => id, - args: (ids) => ({ ids }), + argsFromKeys: (ids) => ({ ids }), } } }, @@ -255,13 +256,13 @@ const gatewaySchema = stitchSchemas({ }); ``` -Stubbed types are easy to setup and effectively work as automated [schema extensions](/docs/stitch-schema-extensions) (in fact, you might not need extensions!). A stubbed type may always be enhanced with additional service-specific fields (like in the [basic example](#basic-example)), however it will require a query in `merge` config as soon as it offers unique data. +Stubbed types are easy to setup and effectively work as automatic [schema extensions](/docs/stitch-schema-extensions) (in fact, you might not need extensions!). A stubbed type may always be expanded with additional service-specific fields (see the [basic example](#basic-example)), however it requires a query in `merge` config as soon as it offers unique data. -In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stubbed type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of an object with a single delegation per external service. +In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stub type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of a type with a single delegation per external service. ### Injected keys -Until now we've only been including a `User` concept in the listing service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving an entire object tree per service with a single delegation to each. Here's an complete example: +Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving entire partial types with a single delegation per service. Here's a complete example: ```js const listings = [ @@ -291,15 +292,15 @@ const listingsSchema = makeExecutableSchema({ }); const users = [ - { id: '1', username: 'bigvader23' }, - { id: '2', username: 'hanshotfirst' }, + { id: '1', email: 'bigvader23@empire.me' }, + { id: '2', email: 'hanshotfirst@solo.net' }, ]; const usersSchema = makeExecutableSchema({ typeDefs: ` type User { id: ID! - username: String! + email: String! } type Listing { @@ -332,13 +333,13 @@ const usersSchema = makeExecutableSchema({ }); ``` -Some important concerns to notice in the above schema: +Some important features to notice in the above schema: -- Listings service `Listing` now provides `buyerId` and `sellerId` keys rather than direct associations. -- Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id` concept. -- Users service defines a `ListingRepresentation` input for external keys, and provides a `_listingsByReps` query that recieves them. +- Listings service `Listing` now provides `buyerId` and `sellerId` keys rather than direct user associations. +- Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id`. +- Users service defines a `ListingRepresentation` input for external keys, and a `_listingsByReps` query that recieves them. -To bring this pattern together, the gateway plays a crucial orchestration role by collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as fully-rendered partial types: +To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as complete partial types: ```js const gatewaySchema = stitchSchemas({ @@ -349,7 +350,8 @@ const gatewaySchema = stitchSchemas({ Listing: { selectionSet: '{ id }', fieldName: 'listingsByIds', - args: (obj) => ({ id: obj.id }), + key: ({ id }) => id, + argsFromKeys: (obj) => ({ id: obj.id }), } } }, @@ -360,7 +362,7 @@ const gatewaySchema = stitchSchemas({ selectionSet: '{ sellerId buyerId }', fieldName: '_listingsByReps', key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), - args: (representations) => ({ representations }), + argsFromKeys: (representations) => ({ representations }), } } }, @@ -369,7 +371,7 @@ const gatewaySchema = stitchSchemas({ }); ``` -Now, you may notice that both `buyerId` and `sellerId` keys are _always_ requested from the listing service, even though they are both really only needed when resolving their respective association field. If we were sensitive to costs associated with keys, then we could be judicious about only selecting them when needed with a field-level selection set mapping: +Now, you may notice that both `sellerId` and `buyerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective association fields. If we were sensitive to costs associated with keys, then we could judiciously select only what we need with a field-level selectionSet mapping: ```js { @@ -382,15 +384,17 @@ Now, you may notice that both `buyerId` and `sellerId` keys are _always_ request }, fieldName: '_listingsByReps', key: ({ sellerId, buyerId }) => ({ sellerId, buyerId }), - args: (representations) => ({ representations }), + argsFromKeys: (representations) => ({ representations }), } } } ``` +One minor disadvantage of this pattern is that the listings service includes ugly `sellerId` and `buyerId` fields. There's no harm in marking these IDs as `@deprecated`, or they may be removed completely from the gateway schema using a [transform](/docs/stitch-combining-schemas#adding-transforms). + ### Federation services -If you're familiar with [Apollo Federation](), then you may notice that the above pattern of injected keys looks familiar... You're right, it's very similar to the `_entities` service design of the [Federation schema specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). +If you're familiar with [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/), then you may notice that the above pattern of injected keys looks familiar... You're right, it's very similar to the `_entities` service design of the [Federation schema specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). In fact, type merging can seamlessly interface with Federation services by sending appropraitely formatted representations to their `_entities` query: @@ -402,15 +406,15 @@ In fact, type merging can seamlessly interface with Federation services by sendi selectionSet: '{ sellerId buyerId }', fieldName: '_entities', key: ({ sellerId, buyerId }) => ({ sellerId, buyerId, __typename: 'Listing' }), - args: (representations) => ({ representations }), + argsFromKeys: (representations) => ({ representations }), } } } ``` -## Custom type resolvers +## Custom merge resolvers -The `merge` property on the `SubschemaConfig` object determines how types are merged, and is a map of `MergedTypeConfig` objects: +The `merge` property of [subschema config](/docs/stitch-combining-schemas#subschema-configs) specifies how types are merged for a service, and provides a map of `MergedTypeConfig` objects: ```ts export interface MergedTypeConfig { @@ -422,9 +426,13 @@ export interface MergedTypeConfig { argsFromKeys?: (keys: ReadonlyArray) => Record; valuesFromResults?: (results: any, keys: ReadonlyArray) => Array; } +``` + +All merged types across subschemas will delegate as necessary to other subschemas implementing the same type using the provided `resolve` function of type `MergedTypeResolver`: +```ts export type MergedTypeResolver = ( - originalResult: any, // initial final result from a subschema + originalResult: any, // initial result from a previous subschema context: Record, // gateway context info: GraphQLResolveInfo, // gateway info subschema: GraphQLSchema | SubschemaConfig, // the additional implementing subschema from which to retrieve data @@ -432,13 +440,7 @@ export type MergedTypeResolver = ( ) => any; ``` -Type merging simply merges types with the same name, but is smart enough to apply the passed subschema transforms prior to merging types, so the types have to be identical on the gateway, not the individual subschema. - -All merged types returned by any subschema will delegate as necessary to subschemas also implementing the type, using the provided `resolve` function of type `MergedTypeResolver`. - -You can also use batch delegation instead of simple delegation by delegating to a root field returning a list and using the `key`, `argsFromKeys`, and `valuesFromResults` properties. See the [batch delegation](#batch-delegation) for more details. - -The simplified magic above happens because if left unspecified, we provide a default type-merging resolver for you, which uses the other `MergedTypeConfig` options (for simple delegation), as follows: +The default `resolve` implementation that powers type merging out of the box looks like this: ```js mergedTypeConfig.resolve = (originalResult, context, info, schemaOrSubschemaConfig, selectionSet) => @@ -455,7 +457,9 @@ mergedTypeConfig.resolve = (originalResult, context, info, schemaOrSubschemaConf }); ``` -When providing your own type-merging resolver, note the very important `skipTypeMerging` setting. Without this option, your gateway will keep busy merging types forever, as each result returned from each subschema will trigger another round of delegation to the other implementing subschemas! +This resolver switches to a batched implementation in the presence of a `mergedTypeConfig.key` function. You may also provide your own custom implementation, however... note the extremely important `skipTypeMerging` setting. Without this option, your gateway will recursively merge types forever! + +Type merging simply merges types of the same name, though it is smart enough to apply provided subschema transforms prior to merging. That means types have to be identical on the gateway, but not the individual subschema. Finally, you may wish to fine-tune which types are merged. Besides taking a boolean value, you can also specify an array of type names, or a function of type `MergeTypeFilter` that takes the potential types and decides dynamically how to merge. diff --git a/website/sidebars.json b/website/sidebars.json index 9f2b8922ad4..c26c3767145 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -25,7 +25,7 @@ "Schema stitching": [ "stitch-combining-schemas", "stitch-schema-extensions", - "stitch-merging-types", + "stitch-type-merging", "stitch-api" ] }, From 3c5f2df76bd7c30c7a083c070931a4f0ae91be28 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 11:35:52 -0400 Subject: [PATCH 07/13] [deploy_website] typo. --- website/docs/stitch-type-merging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index 6c0db045370..c91f92f229b 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -90,7 +90,7 @@ const gatewaySchema = stitchSchemas({ That's it! Under the [subschema config](/docs/stitch-combining-schemas#subschema-configs) `merge` option, each subschema simply provides a query for accessing its respective partial type (services without an expression of the type may omit this). The merge config's `fieldName` specifies a query, `selectionSet` specifies one or more key fields required from other services to perform the query, and `args` formats the preceding partial data into query arguments. This configuration allows type merging to smartly resolve a complete `User`, regardless of which service provides the initial representation of it. -We now have a combined `User` type is in the gateway schema! +We now have a combined `User` type in the gateway schema! ```graphql type User { From 26ef674f1a8d1db60d7934b7d15a5b32947a5f91 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 11:44:08 -0400 Subject: [PATCH 08/13] [deploy_website] another worthwhile point. --- website/docs/stitch-type-merging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index c91f92f229b..1a9ea3edcdb 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -258,7 +258,7 @@ const gatewaySchema = stitchSchemas({ Stubbed types are easy to setup and effectively work as automatic [schema extensions](/docs/stitch-schema-extensions) (in fact, you might not need extensions!). A stubbed type may always be expanded with additional service-specific fields (see the [basic example](#basic-example)), however it requires a query in `merge` config as soon as it offers unique data. -In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stub type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of a type with a single delegation per external service. +In terms of performance, stubbed types match the capabilities of schema extensions—where one external delegation is required _per field_ referencing a stub type. For example, requesting both `buyer` and `seller` fields from a Listing will require two separate delegations to the users service to fetch their respective field selections, even when batching. More advanced patterns like injected keys (discussed below) can outperform stubbing by resolving entire portions of a type with a single delegation per external service. ### Injected keys From 75de94455bd73c4e65000abf88d7bc613da31460 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 11:47:02 -0400 Subject: [PATCH 09/13] [deploy_website] dont overpromise. --- website/docs/stitch-type-merging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index 1a9ea3edcdb..d0c1c28a556 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -262,7 +262,7 @@ In terms of performance, stubbed types match the capabilities of schema extensio ### Injected keys -Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving entire partial types with a single delegation per service. Here's a complete example: +Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving entire partial types with a single delegation per service (per generation of data). Here's a complete example: ```js const listings = [ From 4e9ca70ec1883a9c59873f2f38547d3af301e501 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 14 Aug 2020 11:51:44 -0400 Subject: [PATCH 10/13] [deploy_website] better segue. --- website/docs/stitch-type-merging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index d0c1c28a556..5cf3a06e8df 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -371,7 +371,7 @@ const gatewaySchema = stitchSchemas({ }); ``` -Now, you may notice that both `sellerId` and `buyerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective association fields. If we were sensitive to costs associated with keys, then we could judiciously select only what we need with a field-level selectionSet mapping: +Neat! However, you may notice that both `sellerId` and `buyerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective association fields. If we were sensitive to costs associated with keys, then we could judiciously select only what we need with a field-level selectionSet mapping: ```js { From 16a9c2b0ad936830a11589477c62de25af1b8182 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sat, 15 Aug 2020 00:27:34 -0400 Subject: [PATCH 11/13] [deploy_website] peer review feedback. --- website/docs/stitch-type-merging.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index 5cf3a06e8df..549ec34e094 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -262,7 +262,9 @@ In terms of performance, stubbed types match the capabilities of schema extensio ### Injected keys -Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving entire partial types with a single delegation per service (per generation of data). Here's a complete example: +Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? This pattern has the gateway fetch a set of key fields from one or more initial schemas (listings), then send them as input to the target schema (users), and recieve back a complete type partial. + +While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving any number of fields of any type and selection—all with a single delegation. Here's a complete example: ```js const listings = [ @@ -339,7 +341,7 @@ Some important features to notice in the above schema: - Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id`. - Users service defines a `ListingRepresentation` input for external keys, and a `_listingsByReps` query that recieves them. -To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as complete partial types: +To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as complete type partial: ```js const gatewaySchema = stitchSchemas({ From b7dcf9a76bdbdc73a05bfd3982f98cc1aff59842 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sat, 15 Aug 2020 00:32:00 -0400 Subject: [PATCH 12/13] [deploy_website] typo. --- website/docs/stitch-type-merging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index 549ec34e094..02fef01ea74 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -341,7 +341,7 @@ Some important features to notice in the above schema: - Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id`. - Users service defines a `ListingRepresentation` input for external keys, and a `_listingsByReps` query that recieves them. -To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as complete type partial: +To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as a complete type partial: ```js const gatewaySchema = stitchSchemas({ From 7cfe525bf711ef58a9cc38fd4de8fff6c4a0007f Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sat, 15 Aug 2020 10:04:27 -0400 Subject: [PATCH 13/13] [deploy_website] more feedback. --- website/docs/stitch-type-merging.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index 02fef01ea74..3edac4a796f 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -262,9 +262,9 @@ In terms of performance, stubbed types match the capabilities of schema extensio ### Injected keys -Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? This pattern has the gateway fetch a set of key fields from one or more initial schemas (listings), then send them as input to the target schema (users), and recieve back a complete type partial. +Until now we've always been putting a `User` concept into the listings service. However, what if we reversed that and put a `Listing` concept into the users service? This pattern has the gateway fetch a set of key fields from one or more initial schemas (listings), then send them as input to the target schema (users), and recieve back a complete type. -While this pattern is considerably more sophisticated than stubbed types, it maximizes performance by resolving any number of fields of any type and selection—all with a single delegation. Here's a complete example: +While this pattern is more sophisticated than stubbed types, it maximizes performance by effectively batching multiple fields of any type and selection—all with a single delegation. Here's a complete example: ```js const listings = [ @@ -341,7 +341,7 @@ Some important features to notice in the above schema: - Users service `Listing` now _only_ provides `buyer` and `seller` associations without any need for a shared `id`. - Users service defines a `ListingRepresentation` input for external keys, and a `_listingsByReps` query that recieves them. -To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as a complete type partial: +To bring this all together, the gateway orchestrates collecting plain keys from the listing service, and then injecting them as representations of external records into the users service... from which they return as a complete type: ```js const gatewaySchema = stitchSchemas({ @@ -373,7 +373,9 @@ const gatewaySchema = stitchSchemas({ }); ``` -Neat! However, you may notice that both `sellerId` and `buyerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective association fields. If we were sensitive to costs associated with keys, then we could judiciously select only what we need with a field-level selectionSet mapping: +In summary, the gateway had selected `buyerId` and `sellerId` fields from the listings services, sent those keys as input over to the users service, and then recieved back a complete type resolved with multiple fields of any type and selection. Neat! + +However, you may notice that both `sellerId` and `buyerId` keys are _always_ requested from the listing service, even though they are only needed when resolving their respective associations. If we were sensitive to costs associated with keys, then we could judiciously select only the keys needed for the query with a field-level selectionSet mapping: ```js {