From 08e205813fd8480554381a793ae6e947cf654cb8 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Thu, 15 Feb 2018 13:52:03 +0200 Subject: [PATCH 01/57] Next api Squashed commit of the following: commit a108877910e0556759f92e9acab4643c63be13ff Author: Mikhail Novikov Date: Wed Jan 31 16:05:00 2018 +0200 First transforms commit 334802c1554cee7e6065fdd5108d911379b140ac Merge: 99884b6 e9c0eb5 Author: Mikhail Novikov Date: Fri Jan 26 12:27:57 2018 +0200 Merge remote-tracking branch 'origin/master' into next-api commit 99884b6bad653af6aef49992a4bf73caf9cb9c24 Author: Mikhail Novikov Date: Fri Jan 26 11:57:15 2018 +0200 Restored valid fragments commit f1b24329bb86588f1a39fb4668adc6724f01ceaa Author: Mikhail Novikov Date: Thu Jan 25 14:55:57 2018 +0200 Progress :P commit db2806aae595b06562c876c66aeca0acac1cfeb0 Author: Mikhail Novikov Date: Thu Jan 25 13:35:35 2018 +0200 Fragment replacement commit e9c0eb56c41dffc5e04644c2650cd0080ed77aa4 Author: Mikhail Novikov Date: Wed Jan 24 18:28:12 2018 +0200 v2.19.0 (#594) commit e33c2152992b1c22a3f0b09895e4118aace1c83e Author: Mikhail Novikov Date: Wed Jan 24 16:31:37 2018 +0200 Progress commit 464670f9ee011d543c845b721dbd702ace4e9005 Author: Mikhail Novikov Date: Tue Jan 23 15:03:40 2018 +0200 More progress commit 22d2295d037e32b81bdc2937ab40f97539adca74 Author: Sebastian Richter Date: Tue Jan 23 12:45:49 2018 +0100 Also recreate astNode for fields (#580) * Also recreate astNode for fields In a [previous commit](https://github.com/apollographql/graphql-tools/commit/fd9f6260faa779b2bfc12f1f707cdf2b778d306b) we added the `astNode` property in the `reacreateCompositeType` function. That resulted in cache control working with schema stitching but only for GraphQL Types. By recreating the `astNode` prop also in `fieldToFieldConfig` cache control also works for fields. This is required for caching fields and hence queries. * Add ast to input field node too commit 03ad432387774232d794729f64db27010701337d Author: Renato Benkendorf Date: Tue Jan 23 08:35:00 2018 -0200 Fix resolvers to accept and move forward args with zero or false values (#586) Fixing the args with zero value or false commit 72ac16ffd7b75d1f654f29bf89263093760a7b19 Author: Mikhail Novikov Date: Tue Jan 23 11:37:02 2018 +0200 Upgrade remap-istanbul (#590) commit 54205575701ff2cbba38fa9dfb4f01e68d18e4fb Merge: 24a0f3f 3ef557a Author: Sashko Stubailo Date: Mon Jan 22 11:55:07 2018 -0800 Merge pull request #584 from shackpank/double_deps Remove graphql-subscriptions from devDependencies commit 3ef557acbbe7cdde8afda54261d9439b22b51397 Author: Ollie Buck Date: Fri Jan 19 14:40:03 2018 +0000 Remove graphql-subscriptions from devDependencies commit 7e4efe99d51b79094ed5606faf28a3b69196a27a Author: Mikhail Novikov Date: Thu Jan 18 16:38:13 2018 +0200 Progress commit 24a0f3f4a611666cbfecdbb015ffbe35b5145826 Author: Mikhail Novikov Date: Wed Jan 10 12:36:43 2018 +0200 v2.18.0 (#574) commit 966e102c35d728b1b9782237f4abb03e4fd69ec7 Author: Amit-A Date: Wed Jan 10 02:53:56 2018 -0500 Fixing broken links (#573) commit 94b8f68e419acf00119d115d947d270a8e3eb4f6 Author: Pascal Kaufmann Date: Tue Jan 9 12:51:53 2018 +0100 Fix fragment filtering edge case when a type implements multiple interfaces (#546) * add failing test for multi interface issue * interface / interface always implements an abstract type * changelog * the linter always should be happy * Update CHANGELOG.md commit 608414b0dfd2ff7ba8a27389e0bb8c400fb631c6 Author: Tim Griesser Date: Tue Jan 9 06:15:12 2018 -0500 Allow IEnumResolver values to be number type (#568) * Allow IEnumResolver values to be number type commit 6177034624e21f4d240def6fbb1e984f7ba04814 Author: Mikhail Novikov Date: Tue Jan 9 11:26:04 2018 +0200 v2.17.0 (#571) commit fd9f6260faa779b2bfc12f1f707cdf2b778d306b Author: Sebastian Richter Date: Tue Jan 9 09:25:16 2018 +0100 Include astNode in schema recreation (#569) * Update schemaRecreation.ts `mergeSchemas` does not set `astNode` properties, when recreating types. But `CacheControlExtension.willResolveField` uses the `astNode` property in order to get the `cacheControl` directives. commit 4f489d3e447a89445a2e6ffcd7fbcd599826f1b5 Author: Mikhail Novikov Date: Wed Jan 3 15:40:21 2018 +0200 v2.16.0 (#564) commit c4c5d91655587b13dd3579c1446e42b5e72132a7 Author: VimalRaj Selvam Date: Wed Jan 3 19:00:16 2018 +0530 #544 - Update docs and tests to reflect new strings-as-descriptions pattern in new GraphQL SDL (#559) * #64 - Update docs and tests to reflect new strings-as-descriptions pattern in new GraphQL SDL * Make tests compatible with graphql v0.11 * Fix test errors * Remove extra space * Fix review comments commit 66d58bb5e48a9b7b75a08e94594224494eea7f7b Author: Alvis Tang Date: Wed Jan 3 13:25:59 2018 +0000 fix: correct the dependency issue in typescript caused by #445 (#561) fix #472 commit 342beceff6fc51e5d01d08a1cdfbc634d207e675 Author: Johannes Schickling Date: Wed Jan 3 14:21:35 2018 +0100 Add subscriptions support to `makeRemoteExectuableSchema` (#563) Added subscriptions support to makeRemoteExectuableSchema commit 8e8e34831e7e1bb0835f82d1da7300522f6e65e4 Author: Mikhail Novikov Date: Tue Jan 2 14:50:06 2018 +0200 v2.15.0 (#562) commit f2905ac407a4125aafcb988012d5ba7160bb6b61 Author: Johannes Schickling Date: Tue Jan 2 13:40:56 2018 +0100 Add document validation to `delegateToSchema` (#551) * Add document validation to `delegateToSchema` commit 59592e14b9b2883c50c6adc6b6c8d1c3030bda42 Merge: 3e24c69 3612216 Author: Sashko Stubailo Date: Thu Dec 28 10:02:28 2017 -0800 Merge pull request #556 from enaqx/patch-4 doc: fix Generating schema URL on Mocking page commit 36122163a2d368b0bcadb8b3690bd40841916d06 Author: Sashko Stubailo Date: Thu Dec 28 10:00:16 2017 -0800 Update mocking.md commit 3e24c69d26fd21d5f8919947adf5b5c51bfbf928 Merge: 7089bbf 42d180d Author: Sashko Stubailo Date: Thu Dec 28 09:58:31 2017 -0800 Merge pull request #557 from tbroadley/fix-typos docs: fix typos commit 42d180d38f03ecf6ee28c436aee6b0cbaefb46ca Merge: 5130cf6 7089bbf Author: Sashko Stubailo Date: Thu Dec 28 09:57:11 2017 -0800 Merge branch 'master' into fix-typos commit 7089bbf0f45ce45a4eefc967f39ccaec787552c4 Merge: 3a58286 5048434 Author: Sashko Stubailo Date: Thu Dec 28 09:52:44 2017 -0800 Merge pull request #552 from mklopets/patch-2 Fix link to Apollo Link docs commit 5048434c9ba4a85a2802867359e0a713baca08b3 Author: Marko Klopets Date: Thu Dec 28 14:23:07 2017 +0200 Re-fix link commit 5130cf6ef0eca1f62687ee10a363b0c1252c9469 Author: Thomas Broadley Date: Mon Dec 25 17:45:44 2017 -0500 docs: fix typos commit 80f2f4cd2c98e2c7da585e7ac874be1b72e62a88 Author: Nick Raienko Date: Mon Dec 25 09:19:03 2017 +0200 doc: fix Generating schema URL on Mocking page commit e295019558e9558e3d2284d699ee5ee803b92ea5 Author: Marko Klopets Date: Thu Dec 21 13:18:59 2017 +0200 Fix link to Apollo Link docs commit 3a582868d02fa38b7dde01b84d99677ca84a57da Merge: 259f22e 971e96e Author: Sashko Stubailo Date: Wed Dec 20 11:26:55 2017 -0800 Merge pull request #548 from apollographql/create-2.14.1 v2.14.1 commit 971e96e0aa088bcdbfba930b73230fe7a87f1b77 Author: Mikhail Novikov Date: Wed Dec 20 12:44:14 2017 +0200 v2.14.1 commit 259f22edce2b5c519a7ae4f07ca82645fcba0ccc Author: Mikhail Novikov Date: Wed Dec 20 12:42:16 2017 +0200 Guard against schemas without QueryType (#547) commit 1e3bb14360e037ff05b8767565ce1c4f8bb99628 Author: Mikhail Novikov Date: Tue Dec 19 14:06:13 2017 +0200 Check null resolvers commit 2e2c9b1fc06abc4da3f02c42b8012c6af0be94ac Author: Mikhail Novikov Date: Mon Dec 11 16:09:06 2017 +0200 Simplify API --- .npmignore | 1 + .vscode/settings.json | 1 + CHANGELOG.md | 30 +- README.md | 5 +- designs/connectors.md | 4 +- docs/source/generate-schema.md | 23 +- docs/source/mocking.md | 2 +- docs/source/remote-schemas.md | 2 +- docs/source/resolvers.md | 6 +- docs/source/scalars.md | 14 +- docs/source/schema-stitching.md | 4 +- package.json | 12 +- src/Interfaces.ts | 37 +- src/index.ts | 1 + src/stitching/TypeRegistry.ts | 133 ---- src/stitching/delegateToSchema.ts | 626 +++---------------- src/stitching/linkToFetcher.ts | 2 +- src/stitching/makeRemoteExecutableSchema.ts | 83 ++- src/stitching/mergeSchemas.ts | 504 ++++++++------- src/stitching/resolvers.ts | 103 +++ src/stitching/schemaRecreation.ts | 173 ++++- src/stitching/typeFromAST.ts | 76 ++- src/test/testMakeRemoteExecutableSchema.ts | 48 ++ src/test/testMergeSchemas.ts | 457 +++++++++++--- src/test/testSchemaGenerator.ts | 45 +- src/test/testTransforms.ts | 129 ++++ src/test/testingSchemas.ts | 151 ++++- src/test/tests.ts | 2 + src/transforms/AddArgumentsAsVariables.ts | 196 ++++++ src/transforms/AddTypenameToAbstract.ts | 75 +++ src/transforms/CheckResultAndHandleErrors.ts | 14 + src/transforms/FilterToSchema.ts | 304 +++++++++ src/transforms/ReplaceFieldWithFragment.ts | 78 +++ src/transforms/index.ts | 16 + src/transforms/makeSimpleTransformSchema.ts | 23 + src/transforms/transforms.ts | 44 ++ src/transforms/visitSchema.ts | 142 +++++ 37 files changed, 2476 insertions(+), 1090 deletions(-) delete mode 100644 src/stitching/TypeRegistry.ts create mode 100644 src/stitching/resolvers.ts create mode 100644 src/test/testMakeRemoteExecutableSchema.ts create mode 100644 src/test/testTransforms.ts create mode 100644 src/transforms/AddArgumentsAsVariables.ts create mode 100644 src/transforms/AddTypenameToAbstract.ts create mode 100644 src/transforms/CheckResultAndHandleErrors.ts create mode 100644 src/transforms/FilterToSchema.ts create mode 100644 src/transforms/ReplaceFieldWithFragment.ts create mode 100644 src/transforms/index.ts create mode 100644 src/transforms/makeSimpleTransformSchema.ts create mode 100644 src/transforms/transforms.ts create mode 100644 src/transforms/visitSchema.ts diff --git a/.npmignore b/.npmignore index f27a6939860..927190ca980 100644 --- a/.npmignore +++ b/.npmignore @@ -2,6 +2,7 @@ !dist/ !dist/* !dist/stitching/* +!dist/transforms/* !package.json !*.md !*.png diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d988b1ebc0..e93660f7dbd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "editor.insertSpaces": true, "editor.rulers": [110], "editor.wordWrapColumn": 110, + "prettier.semi": true, "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, "prettier.singleQuote": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9259e0bf95e..fda35626b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,33 @@ ### vNEXT -* ... +### v2.19.0 + +* Also recreate `astNode` property for fields, not only types, when recreating schemas. [PR #580](https://github.com/apollographql/graphql-tools/pull/580) +* Fix `delegateToSchema.js` to accept and move forward args with zero or false values [PR #586](https://github.com/apollographql/graphql-tools/pull/586) + +### v2.18.0 + +* Fix a bug where inline fragments got filtered in merged schemas when a type implemented multiple interfaces [PR #546](https://github.com/apollographql/graphql-tools/pull/546) +* IEnumResolver value can be a `number` type [PR #568](https://github.com/apollographql/graphql-tools/pull/568) + +### v2.17.0 + +* Include `astNode` property in schema recreation [PR #569](https://github.com/apollographql/graphql-tools/pull/569) + +### v2.16.0 + +* Added GraphQL Subscriptions support for schema stitching and `makeRemoteExecutableSchema` [PR #563](https://github.com/apollographql/graphql-tools/pull/563) +* Make `apollo-link` a direct dependency [PR #561](https://github.com/apollographql/graphql-tools/pull/561) +* Update tests to use `graphql-js@0.12` docstring format [PR #559](https://github.com/apollographql/graphql-tools/pull/559) + +### v2.15.0 + +* Validate query before delegation [PR #551](https://github.com/apollographql/graphql-tools/pull/551) + +### v2.14.1 + +* Add guard against invalid schemas being constructed from AST [PR #547](https://github.com/apollographql/graphql-tools/pull/547) ### v2.14.0 @@ -177,7 +203,7 @@ Update to add support for `graphql@0.12`, and drop versions before `0.11` from t * Removed testing on Node 5 ([@DxCx](https://github.com/DxCx) in [#129](https://github.com/apollostack/graphql-tools/pull/129)) -* Changed GraphQL typings requirment from peer to standard ([@DxCx](https://github.com/DxCx) in [#129](https://github.com/apollostack/graphql-tools/pull/129)) +* Changed GraphQL typings requirement from peer to standard ([@DxCx](https://github.com/DxCx) in [#129](https://github.com/apollostack/graphql-tools/pull/129)) * Change the missing resolve function validator to show a warning instead of an error ([@nicolaslopezj](https://github.com/nicolaslopezj) in [#134](https://github.com/apollostack/graphql-tools/pull/134)) diff --git a/README.md b/README.md index bffa4142608..e58ee4ae577 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ type Author { id: ID! # the ! means that every author object _must_ have an id firstName: String lastName: String - posts: [Post] # the list of Posts by this author + """ + the list of Posts by this author + """ + posts: [Post] } type Post { diff --git a/designs/connectors.md b/designs/connectors.md index 7a075675452..c1077b16873 100644 --- a/designs/connectors.md +++ b/designs/connectors.md @@ -37,7 +37,7 @@ In this example, an author has multiple posts, and each post has one author. Here's an illustration for how connectors and models would look like for this example if Authors and Posts were stored in MySQL, but view counts in MongoDB: -![Connectors are database-specfic, models are application-specific](connector-model-diagram.png) +![Connectors are database-specific, models are application-specific](connector-model-diagram.png) The Posts model connects to both SQL and MongoDB. Title, text and authorId come from SQL, the view count comes from MongoDB. @@ -60,7 +60,7 @@ Both batching and caching are more important in GraphQL than in traditional endp Models are the glue between connectors - which are backend-specific - and GraphQL types - which are app-specific. They are very similar to models in ORMs, such as Rails' Active Record. -Let's say for example that you have two types, Author and Post, which are both stored in MySQL. Rather than calling the MySQL connector directly from your resolve funcitons, you should create models for Author and Post, which use the MongoDB connector. This additional level of abstraction helps separate the data fetching logic from the GraphQL schema, which makes reusing and refactoring it easier. +Let's say for example that you have two types, Author and Post, which are both stored in MySQL. Rather than calling the MySQL connector directly from your resolve functions, you should create models for Author and Post, which use the MongoDB connector. This additional level of abstraction helps separate the data fetching logic from the GraphQL schema, which makes reusing and refactoring it easier. In the example schema above, the Authors model would have the following methods: ``` diff --git a/docs/source/generate-schema.md b/docs/source/generate-schema.md index 930ee522d70..a39f65b5fca 100644 --- a/docs/source/generate-schema.md +++ b/docs/source/generate-schema.md @@ -17,7 +17,10 @@ const typeDefs = ` id: Int! firstName: String lastName: String - posts: [Post] # the list of Posts by this author + """ + the list of Posts by this author + """ + posts: [Post] } type Post { @@ -279,19 +282,29 @@ const typeDefs = [`

Descriptions & Deprecations

GraphiQL has built-in support for displaying docstrings with markdown syntax. You can easily add docstrings to types, fields and arguments like below: + ``` -# Description for the type +""" +Description for the type +""" type MyObjectType { - # Description for field + """ + Description for field + Supports multi-line description + """ myField: String! otherField( - # Description for argument + """ + Description for argument + """ arg: Int ) oldField( - # Description for argument + """ + Description for argument + """ arg: Int ) @deprecated(reason: "Use otherField instead.") } diff --git a/docs/source/mocking.md b/docs/source/mocking.md index 1fdb0fadc8d..4d77317a731 100644 --- a/docs/source/mocking.md +++ b/docs/source/mocking.md @@ -13,7 +13,7 @@ Let's take a look at how we can mock a GraphQL schema with just one line of code [See a complete runnable example on Launchpad.](https://launchpad.graphql.com/98lq7vz8r) -To start, let's grab the schema definition string from the `makeExecutableSchema` example [in the "Generating a schema" article](/tools/graphql-tools/generate-schema.html#example). +To start, let's grab the schema definition string from the `makeExecutableSchema` example [in the "Generating a schema" article](./generate-schema.html#example). ```js import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools'; diff --git a/docs/source/remote-schemas.md b/docs/source/remote-schemas.md index 220d60b57f4..dfede5be250 100644 --- a/docs/source/remote-schemas.md +++ b/docs/source/remote-schemas.md @@ -39,7 +39,7 @@ A link is a function capable of retrieving GraphQL results. It is the same way t Link API -Since graphql-tools supports using a link for the network layer, the API is the same as you would write on the client. To learn more about how Apollo Link works, check out the [docs](https://apollo-links-docs.netlify.com/links); Both GraphQL and Apollo Links have slightly varying concepts of what `context` is used for. To make it easy to use your GraphQL context to create your Apollo Link context, `makeRemoteExecutableSchema` attaches the context from the graphql resolver onto the link context under `graphqlContext`. +Since graphql-tools supports using a link for the network layer, the API is the same as you would write on the client. To learn more about how Apollo Link works, check out the [docs](https://www.apollographql.com/docs/link/); Both GraphQL and Apollo Links have slightly varying concepts of what `context` is used for. To make it easy to use your GraphQL context to create your Apollo Link context, `makeRemoteExecutableSchema` attaches the context from the graphql resolver onto the link context under `graphqlContext`. Basic usage diff --git a/docs/source/resolvers.md b/docs/source/resolvers.md index 89b6ab7aceb..206ef6eecf2 100644 --- a/docs/source/resolvers.md +++ b/docs/source/resolvers.md @@ -42,9 +42,9 @@ fieldName(obj, args, context, info) { result } These arguments have the following meanings and conventional names: -1. `obj`: The object that contains the result returned from the resolver on the parent field, or, in the case of a top-level `Query` field, the `rootValue` passed from the [server configuration](/tools/apollo-server/setup.html). This argument enables the nested nature of GraphQL queries. +1. `obj`: The object that contains the result returned from the resolver on the parent field, or, in the case of a top-level `Query` field, the `rootValue` passed from the [server configuration](/docs/apollo-server/setup.html). This argument enables the nested nature of GraphQL queries. 2. `args`: An object with the arguments passed into the field in the query. For example, if the field was called with `author(name: "Ada")`, the `args` object would be: `{ "name": "Ada" }`. -3. `context`: This is an object shared by all resolvers in a particular query, and is used to contain per-request state, including authentication information, dataloader instances, and anything else that should be taken into account when resolving the query. If you're using Apollo Server, [read about how to set the context in the setup documentation](/tools/apollo-server/setup.html). +3. `context`: This is an object shared by all resolvers in a particular query, and is used to contain per-request state, including authentication information, dataloader instances, and anything else that should be taken into account when resolving the query. If you're using Apollo Server, [read about how to set the context in the setup documentation](/docs/apollo-server/setup.html). 4. `info`: This argument should only be used in advanced cases, but it contains information about the execution state of the query, including the field name, path to the field from the root, and more. It's only documented in the [GraphQL.js source code](https://github.com/graphql/graphql-js/blob/c82ff68f52722c20f10da69c9e50a030a1f218ae/src/type/definition.js#L489-L500). ### Resolver result format @@ -178,7 +178,7 @@ Modules and extensions built by the community. Composition library for GraphQL, with helpers to combine multiple resolvers into one, specify dependencies between fields, and more. -When developing a GraphQL server, it is common to perform some authorization logic on your resolvers, usually based on the context of a request. With `graphql-resolvers` you can easily accomplish that and still make the code decoupled - thus testable - by combining multiple sigle-logic resolvers into one. +When developing a GraphQL server, it is common to perform some authorization logic on your resolvers, usually based on the context of a request. With `graphql-resolvers` you can easily accomplish that and still make the code decoupled - thus testable - by combining multiple single-logic resolvers into one. The following is an example of a simple logged-in authorization logic: diff --git a/docs/source/scalars.md b/docs/source/scalars.md index 72dfdcda0bd..11478c82e99 100644 --- a/docs/source/scalars.md +++ b/docs/source/scalars.md @@ -208,11 +208,8 @@ You can use it in your schema anywhere you could use a scalar: ```graphql type Query { - # As a return value - favoriteColor: AllowedColor - - # As an argument - avatar(borderColor: AllowedColor): String + favoriteColor: AllowedColor # As a return value + avatar(borderColor: AllowedColor): String # As an argument } ``` @@ -249,11 +246,8 @@ const typeDefs = ` } type Query { - # As a return value - favoriteColor: AllowedColor - - # As an argument - avatar(borderColor: AllowedColor): String + favoriteColor: AllowedColor # As a return value + avatar(borderColor: AllowedColor): String # As an argument } `; diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 4e40416069a..281faec8e84 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -112,7 +112,7 @@ You won't be able to query `User.chirps` or `Chirp.author` yet however, because So what should these resolvers look like? -When we resolve `User.chirps` or `Chirp.author`, we want to delegate to the revelant root fields. To get from a user to its chirps for example, we'll want to use the `id` of the user to call `chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call into `userById`. +When we resolve `User.chirps` or `Chirp.author`, we want to delegate to the relevant root fields. To get from a user to its chirps for example, we'll want to use the `id` of the user to call `chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call into `userById`. Resolvers specified as part of `mergeSchema` have access to a `delegate` function that allows you to delegate to root fields. @@ -220,7 +220,7 @@ resolvers: mergeInfo => ({ #### mergeInfo and delegate -`mergeInfo` currenty is an object with one property - `delegate`. It looks like this: +`mergeInfo` currently is an object with one property - `delegate`. It looks like this: ```js type MergeInfo = { diff --git a/package.json b/package.json index f84beafd098..8711f22b669 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "2.14.0", + "version": "3.0.0-alpha.5", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -49,34 +49,34 @@ "homepage": "https://github.com/apollostack/graphql-tools#readme", "dependencies": { "apollo-utilities": "^1.0.1", + "apollo-link": "^1.0.0", "deprecated-decorator": "^0.1.6", + "graphql-subscriptions": "^0.5.6", "uuid": "^3.1.0" }, "peerDependencies": { "graphql": "^0.11.0 || ^0.12.0" }, "devDependencies": { - "@types/chai": "4.0.4", + "@types/chai": "4.0.10", "@types/graphql": "0.11.7", "@types/mocha": "^2.2.44", "@types/node": "^8.0.47", "@types/uuid": "^3.4.3", "@types/zen-observable": "^0.5.3", - "apollo-link": "^1.0.0", "body-parser": "^1.18.2", "chai": "^4.1.2", "express": "^4.16.2", "graphql": "^0.12.3", - "graphql-subscriptions": "^0.5.4", "graphql-type-json": "^0.1.4", "istanbul": "^0.4.5", "iterall": "^1.1.3", "mocha": "^4.0.1", "prettier": "^1.7.4", - "remap-istanbul": "0.9.5", + "remap-istanbul": "0.9.6", "rimraf": "^2.6.2", "source-map-support": "^0.5.0", "tslint": "^5.8.0", - "typescript": "2.6.1" + "typescript": "2.6.2" } } diff --git a/src/Interfaces.ts b/src/Interfaces.ts index e22490c7c1f..a0ae63531c9 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -8,6 +8,7 @@ import { GraphQLIsTypeOfFn, GraphQLTypeResolver, GraphQLScalarType, + GraphQLNamedType, DocumentNode, } from 'graphql'; @@ -29,12 +30,14 @@ export interface IResolverOptions { export type MergeInfo = { delegate: ( + schemaName: string, type: 'query' | 'mutation' | 'subscription', fieldName: string, args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, ) => any; + getSubSchema: (schemaName: string) => GraphQLSchema; }; export type IFieldResolver = ( @@ -49,7 +52,7 @@ export type ITypeDefinitions = ITypedef | ITypedef[]; export type IResolverObject = { [key: string]: IFieldResolver | IResolverOptions; }; -export type IEnumResolver = { [key: string]: string }; +export type IEnumResolver = { [key: string]: string | number }; export interface IResolvers { [key: string]: | (() => any) @@ -119,3 +122,35 @@ export interface IMockServer { vars?: { [key: string]: any }, ) => Promise; } + +export type MergeTypeCandidate = { + schemaName: string; + schema?: GraphQLSchema; + type: GraphQLNamedType; +}; + +export type TypeWithResolvers = { + type: GraphQLNamedType; + resolvers?: IResolvers; +}; + +export type VisitTypeResult = GraphQLNamedType | TypeWithResolvers | null; + +export type VisitType = ( + name: string, + candidates: Array, +) => VisitTypeResult; + +export type ResolveType = (type: T) => T; + +export type Operation = 'query' | 'mutation' | 'subscription'; + +export type Request = { + document: DocumentNode; + variables: Record; + extensions?: Record; +}; + +export type Result = ExecutionResult & { + extensions?: Record; +}; diff --git a/src/index.ts b/src/index.ts index a758157fff0..826d2d2c9dd 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './schemaGenerator'; export * from './mock'; export * from './stitching'; +export * from './transforms'; diff --git a/src/stitching/TypeRegistry.ts b/src/stitching/TypeRegistry.ts deleted file mode 100644 index d0bc9e592af..00000000000 --- a/src/stitching/TypeRegistry.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - GraphQLSchema, - GraphQLNonNull, - GraphQLList, - GraphQLNamedType, - GraphQLType, - isNamedType, - getNamedType, - InlineFragmentNode, - Kind, - parse, -} from 'graphql'; - -export default class TypeRegistry { - public fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }; - private types: { [key: string]: GraphQLNamedType }; - private schemaByField: { - query: { [key: string]: GraphQLSchema }; - mutation: { [key: string]: GraphQLSchema }; - subscription: { [key: string]: GraphQLSchema }; - }; - - constructor() { - this.types = {}; - this.schemaByField = { - query: {}, - mutation: {}, - subscription: {}, - }; - this.fragmentReplacements = {}; - } - - public getSchemaByField( - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, - ): GraphQLSchema { - return this.schemaByField[operation][fieldName]; - } - - public getAllTypes(): Array { - return Object.keys(this.types).map(name => this.types[name]); - } - - public getType(name: string): GraphQLNamedType { - if (!this.types[name]) { - throw new Error(`No such type: ${name}`); - } - return this.types[name]; - } - - public resolveType(type: T): T { - if (type instanceof GraphQLList) { - return new GraphQLList(this.resolveType(type.ofType)) as T; - } else if (type instanceof GraphQLNonNull) { - return new GraphQLNonNull(this.resolveType(type.ofType)) as T; - } else if (isNamedType(type)) { - return this.getType(getNamedType(type).name) as T; - } else { - return type; - } - } - - public addSchema(schema: GraphQLSchema) { - const query = schema.getQueryType(); - if (query) { - const fieldNames = Object.keys(query.getFields()); - fieldNames.forEach(field => { - this.schemaByField.query[field] = schema; - }); - } - - const mutation = schema.getMutationType(); - if (mutation) { - const fieldNames = Object.keys(mutation.getFields()); - fieldNames.forEach(field => { - this.schemaByField.mutation[field] = schema; - }); - } - - const subscription = schema.getSubscriptionType(); - if (subscription) { - const fieldNames = Object.keys(subscription.getFields()); - fieldNames.forEach(field => { - this.schemaByField.subscription[field] = schema; - }); - } - } - - public addType( - name: string, - type: GraphQLNamedType, - onTypeConflict?: ( - leftType: GraphQLNamedType, - rightType: GraphQLNamedType, - ) => GraphQLNamedType, - ): void { - if (this.types[name]) { - if (onTypeConflict) { - type = onTypeConflict(this.types[name], type); - } else { - throw new Error(`Type name conflict: ${name}`); - } - } - this.types[name] = type; - } - - public addFragment(typeName: string, fieldName: string, fragment: string) { - if (!this.fragmentReplacements[typeName]) { - this.fragmentReplacements[typeName] = {}; - } - this.fragmentReplacements[typeName][ - fieldName - ] = parseFragmentToInlineFragment(fragment); - } -} - -function parseFragmentToInlineFragment( - definitions: string, -): InlineFragmentNode { - const document = parse(definitions); - for (const definition of document.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - return { - kind: Kind.INLINE_FRAGMENT, - typeCondition: definition.typeCondition, - selectionSet: definition.selectionSet, - }; - } - } - throw new Error('Could not parse fragment'); -} diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index b60b0aa2594..90f7d2cafde 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -2,573 +2,125 @@ import { DocumentNode, FieldNode, FragmentDefinitionNode, - FragmentSpreadNode, - GraphQLField, - GraphQLInputType, - GraphQLInterfaceType, - GraphQLList, - GraphQLNamedType, - GraphQLNonNull, - GraphQLObjectType, GraphQLResolveInfo, GraphQLSchema, - GraphQLType, - GraphQLUnionType, - InlineFragmentNode, Kind, OperationDefinitionNode, SelectionSetNode, - TypeNameMetaFieldDef, - TypeNode, - VariableDefinitionNode, - VariableNode, - execute, - visit, + SelectionNode, subscribe, + graphql, + print, + validate, + VariableDefinitionNode, } from 'graphql'; -import { checkResultAndHandleErrors } from './errors'; +import { Operation, Request } from '../Interfaces'; +import { + Transform, + applyRequestTransforms, + applyResultTransforms, +} from '../transforms/transforms'; +import AddArgumentsAsVariables from '../transforms/AddArgumentsAsVariables'; +import FilterToSchema from '../transforms/FilterToSchema'; +import AddTypenameToAbstract from '../transforms/AddTypenameToAbstract'; +import CheckResultAndHandleErrors from '../transforms/CheckResultAndHandleErrors'; export default async function delegateToSchema( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, + targetSchema: GraphQLSchema, + targetOperation: Operation, + targetField: string, args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, + transforms: Array, ): Promise { - let type; - if (operation === 'mutation') { - type = schema.getMutationType(); - } else if (operation === 'subscription') { - type = schema.getSubscriptionType(); - } else { - type = schema.getQueryType(); + const rawDocument: DocumentNode = createDocument( + targetField, + targetOperation, + info.fieldNodes, + Object.keys(info.fragments).map( + fragmentName => info.fragments[fragmentName], + ), + info.operation.variableDefinitions, + ); + + const rawRequest: Request = { + document: rawDocument, + variables: info.variableValues as Record, + }; + + transforms = [ + ...transforms, + AddArgumentsAsVariables(targetSchema, args), + FilterToSchema(targetSchema), + AddTypenameToAbstract(targetSchema), + CheckResultAndHandleErrors(info, targetField), + ]; + + const processedRequest = applyRequestTransforms(rawRequest, transforms); + + const errors = validate(targetSchema, processedRequest.document); + if (errors.length > 0) { + throw errors; } - if (type) { - const graphqlDoc: DocumentNode = createDocument( - schema, - fragmentReplacements, - type, - fieldName, - operation, - info.fieldNodes, - info.fragments, - info.operation.variableDefinitions, - ); - const operationDefinition = graphqlDoc.definitions.find( - ({ kind }) => kind === Kind.OPERATION_DEFINITION, + if (targetOperation === 'query' || targetOperation === 'mutation') { + const rawResult = await graphql( + targetSchema, + print(processedRequest.document), + info.rootValue, + context, + processedRequest.variables, ); - let variableValues = {}; - if ( - operationDefinition && - operationDefinition.kind === Kind.OPERATION_DEFINITION && - operationDefinition.variableDefinitions - ) { - operationDefinition.variableDefinitions.forEach(definition => { - const key = definition.variable.name.value; - // (XXX) This is kinda hacky - let actualKey = key; - if (actualKey.startsWith('_')) { - actualKey = actualKey.slice(1); - } - const value = args[actualKey] || args[key] || info.variableValues[key]; - variableValues[key] = value; - }); - } - if (operation === 'query' || operation === 'mutation') { - const result = await execute( - schema, - graphqlDoc, - info.rootValue, - context, - variableValues, - ); - return checkResultAndHandleErrors(result, info, fieldName); - } - - if (operation === 'subscription') { - return subscribe( - schema, - graphqlDoc, - info.rootValue, - context, - variableValues, - ); - } + const result = applyResultTransforms(rawResult, transforms); + return result; } - throw new Error('Could not forward to merged schema'); + if (targetOperation === 'subscription') { + // apply result processing ??? + return subscribe( + targetSchema, + processedRequest.document, + info.rootValue, + context, + processedRequest.variables, + ); + } } export function createDocument( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - type: GraphQLObjectType, - rootFieldName: string, - operation: 'query' | 'mutation' | 'subscription', - selections: Array, - fragments: { [fragmentName: string]: FragmentDefinitionNode }, - variableDefinitions?: Array, + targetField: string, + targetOperation: Operation, + selections: Array, + fragments: Array, + variables: Array, ): DocumentNode { - const rootField = type.getFields()[rootFieldName]; - const newVariables: Array<{ arg: string; variable: string }> = []; - const rootSelectionSet = { + const originalSelection = selections[0] as FieldNode; + const rootField: FieldNode = { + kind: Kind.FIELD, + alias: null, + arguments: originalSelection.arguments, + selectionSet: originalSelection.selectionSet, + name: { + kind: Kind.NAME, + value: targetField, + }, + }; + const rootSelectionSet: SelectionSetNode = { kind: Kind.SELECTION_SET, - // (XXX) This (wrongly) assumes only having one fieldNode - selections: selections.map(selection => { - if (selection.kind === Kind.FIELD) { - const { selection: newSelection, variables } = processRootField( - selection, - rootFieldName, - rootField, - ); - newVariables.push(...variables); - return newSelection; - } else { - return selection; - } - }), + selections: [rootField], }; - const newVariableDefinitions = newVariables.map(({ arg, variable }) => { - const argDef = rootField.args.find(rootArg => rootArg.name === arg); - if (!argDef) { - throw new Error('Unexpected missing arg'); - } - const typeName = typeToAst(argDef.type); - return { - kind: Kind.VARIABLE_DEFINITION, - variable: { - kind: Kind.VARIABLE, - name: { - kind: Kind.NAME, - value: variable, - }, - }, - type: typeName, - }; - }); - - const { - selectionSet, - fragments: processedFragments, - usedVariables, - } = filterSelectionSetDeep( - schema, - fragmentReplacements, - type, - rootSelectionSet, - fragments, - ); - const operationDefinition: OperationDefinitionNode = { kind: Kind.OPERATION_DEFINITION, - operation, - variableDefinitions: [ - ...(variableDefinitions || []).filter( - variableDefinition => - usedVariables.indexOf(variableDefinition.variable.name.value) !== -1, - ), - ...newVariableDefinitions, - ], - selectionSet, - }; - - const newDoc: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: [operationDefinition, ...processedFragments], + operation: targetOperation, + variableDefinitions: variables, + selectionSet: rootSelectionSet, }; - return newDoc; -} - -function processRootField( - selection: FieldNode, - rootFieldName: string, - rootField: GraphQLField, -): { - selection: FieldNode; - variables: Array<{ arg: string; variable: string }>; -} { - const existingArguments = selection.arguments || []; - const existingArgumentNames = existingArguments.map(arg => arg.name.value); - const allowedArguments = rootField.args.map(arg => arg.name); - const missingArgumentNames = difference( - allowedArguments, - existingArgumentNames, - ); - const extraArguments = difference(existingArgumentNames, allowedArguments); - const filteredExistingArguments = existingArguments.filter( - arg => extraArguments.indexOf(arg.name.value) === -1, - ); - const variables: Array<{ arg: string; variable: string }> = []; - const missingArguments = missingArgumentNames.map(name => { - // (XXX): really needs better var generation - const variableName = `_${name}`; - variables.push({ - arg: name, - variable: variableName, - }); - return { - kind: Kind.ARGUMENT, - name: { - kind: Kind.NAME, - value: name, - }, - value: { - kind: Kind.VARIABLE, - name: { - kind: Kind.NAME, - value: variableName, - }, - }, - }; - }); - return { - selection: { - kind: Kind.FIELD, - alias: null, - arguments: [...filteredExistingArguments, ...missingArguments], - selectionSet: selection.selectionSet, - name: { - kind: Kind.NAME, - value: rootFieldName, - }, - }, - variables, - }; -} - -function filterSelectionSetDeep( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - type: GraphQLType, - selectionSet: SelectionSetNode, - fragments: { [fragmentName: string]: FragmentDefinitionNode }, -): { - selectionSet: SelectionSetNode; - fragments: Array; - usedVariables: Array; -} { - const validFragments: Array = []; - Object.keys(fragments).forEach(fragmentName => { - const fragment = fragments[fragmentName]; - const typeName = fragment.typeCondition.name.value; - const innerType = schema.getType(typeName); - if (innerType) { - validFragments.push(fragment); - } - }); - let { - selectionSet: newSelectionSet, - usedFragments: remainingFragments, - usedVariables, - } = filterSelectionSet( - schema, - fragmentReplacements, - type, - selectionSet, - validFragments, - ); - - const newFragments = {}; - // (XXX): So this will break if we have a fragment that only has link fields - while (remainingFragments.length > 0) { - const name = remainingFragments.pop(); - if (newFragments[name]) { - continue; - } else { - const nextFragment = fragments[name]; - if (!name) { - throw new Error(`Could not find fragment ${name}`); - } - const typeName = nextFragment.typeCondition.name.value; - const innerType = schema.getType(typeName); - if (!innerType) { - continue; - } - const { - selectionSet: fragmentSelectionSet, - usedFragments: fragmentUsedFragments, - usedVariables: fragmentUsedVariables, - } = filterSelectionSet( - schema, - fragmentReplacements, - innerType, - nextFragment.selectionSet, - validFragments, - ); - remainingFragments = union(remainingFragments, fragmentUsedFragments); - usedVariables = union(usedVariables, fragmentUsedVariables); - newFragments[name] = { - kind: Kind.FRAGMENT_DEFINITION, - name: { - kind: Kind.NAME, - value: name, - }, - typeCondition: nextFragment.typeCondition, - selectionSet: fragmentSelectionSet, - }; - } - } - const newFragmentValues: Array = Object.keys( - newFragments, - ).map(name => newFragments[name]); - return { - selectionSet: newSelectionSet, - fragments: newFragmentValues, - usedVariables, - }; -} - -function filterSelectionSet( - schema: GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - type: GraphQLType, - selectionSet: SelectionSetNode, - validFragments: Array, -): { - selectionSet: SelectionSetNode; - usedFragments: Array; - usedVariables: Array; -} { - const usedFragments: Array = []; - const usedVariables: Array = []; - const typeStack: Array = [type]; - const filteredSelectionSet = visit(selectionSet, { - [Kind.FIELD]: { - enter(node: FieldNode): null | undefined | FieldNode { - let parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if ( - parentType instanceof GraphQLObjectType || - parentType instanceof GraphQLInterfaceType - ) { - const fields = parentType.getFields(); - const field = - node.name.value === '__typename' - ? TypeNameMetaFieldDef - : fields[node.name.value]; - if (!field) { - return null; - } else { - typeStack.push(field.type); - } - } else if ( - parentType instanceof GraphQLUnionType && - node.name.value === '__typename' - ) { - typeStack.push(TypeNameMetaFieldDef.type); - } - }, - leave() { - typeStack.pop(); - }, - }, - [Kind.SELECTION_SET]( - node: SelectionSetNode, - ): SelectionSetNode | null | undefined { - const parentType: GraphQLType = resolveType( - typeStack[typeStack.length - 1], - ); - const parentTypeName = parentType.name; - let selections = node.selections; - if ( - (parentType instanceof GraphQLInterfaceType || - parentType instanceof GraphQLUnionType) && - !selections.find( - _ => - (_ as FieldNode).kind === Kind.FIELD && - (_ as FieldNode).name.value === '__typename', - ) - ) { - selections = selections.concat({ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: '__typename', - }, - }); - } - - if (fragmentReplacements[parentTypeName]) { - selections.forEach(selection => { - if (selection.kind === Kind.FIELD) { - const name = selection.name.value; - const fragment = fragmentReplacements[parentTypeName][name]; - if (fragment) { - selections = selections.concat(fragment); - } - } - }); - } - - if (selections !== node.selections) { - return { - ...node, - selections, - }; - } - }, - [Kind.FRAGMENT_SPREAD](node: FragmentSpreadNode): null | undefined { - const fragmentFiltered = validFragments.filter( - frg => frg.name.value === node.name.value, - ); - const fragment = fragmentFiltered[0]; - if (fragment) { - if (fragment.typeCondition) { - const innerType = schema.getType(fragment.typeCondition.name.value); - const parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if (!implementsAbstractType(parentType, innerType)) { - return null; - } - } - usedFragments.push(node.name.value); - return; - } else { - return null; - } - }, - [Kind.INLINE_FRAGMENT]: { - enter(node: InlineFragmentNode): null | undefined { - if (node.typeCondition) { - const innerType = schema.getType(node.typeCondition.name.value); - const parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if (implementsAbstractType(parentType, innerType)) { - typeStack.push(innerType); - } else { - return null; - } - } - }, - leave(node: InlineFragmentNode): null | undefined { - if (node.typeCondition) { - const innerType = schema.getType(node.typeCondition.name.value); - if (innerType) { - typeStack.pop(); - } else { - return null; - } - } - }, - }, - [Kind.VARIABLE](node: VariableNode) { - usedVariables.push(node.name.value); - }, - }); - - return { - selectionSet: filteredSelectionSet, - usedFragments, - usedVariables, + kind: Kind.DOCUMENT, + definitions: [operationDefinition, ...fragments], }; } - -function resolveType(type: GraphQLType): GraphQLNamedType { - let lastType = type; - while ( - lastType instanceof GraphQLNonNull || - lastType instanceof GraphQLList - ) { - lastType = lastType.ofType; - } - return lastType; -} - -function implementsAbstractType( - parent: GraphQLType, - child: GraphQLType, - bail: boolean = false, -): boolean { - if (parent === child) { - return true; - } else if ( - parent instanceof GraphQLInterfaceType && - child instanceof GraphQLObjectType - ) { - return child.getInterfaces().indexOf(parent) !== -1; - } else if ( - parent instanceof GraphQLUnionType && - child instanceof GraphQLObjectType - ) { - return parent.getTypes().indexOf(child) !== -1; - } else if (parent instanceof GraphQLObjectType && !bail) { - return implementsAbstractType(child, parent, true); - } - - return false; -} - -function typeToAst(type: GraphQLInputType): TypeNode { - if (type instanceof GraphQLNonNull) { - const innerType = typeToAst(type.ofType); - if ( - innerType.kind === Kind.LIST_TYPE || - innerType.kind === Kind.NAMED_TYPE - ) { - return { - kind: Kind.NON_NULL_TYPE, - type: innerType, - }; - } else { - throw new Error('Incorrent inner non-null type'); - } - } else if (type instanceof GraphQLList) { - return { - kind: Kind.LIST_TYPE, - type: typeToAst(type.ofType), - }; - } else { - return { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: type.toString(), - }, - }; - } -} - -function union(...arrays: Array>): Array { - const cache: { [key: string]: Boolean } = {}; - const result: Array = []; - arrays.forEach(array => { - array.forEach(item => { - if (!cache[item]) { - cache[item] = true; - result.push(item); - } - }); - }); - return result; -} - -function difference( - from: Array, - ...arrays: Array> -): Array { - const cache: { [key: string]: Boolean } = {}; - arrays.forEach(array => { - array.forEach(item => { - cache[item] = true; - }); - }); - return from.filter(item => !cache[item]); -} diff --git a/src/stitching/linkToFetcher.ts b/src/stitching/linkToFetcher.ts index 18a4b1bc7d7..cd835c30f6b 100644 --- a/src/stitching/linkToFetcher.ts +++ b/src/stitching/linkToFetcher.ts @@ -45,7 +45,7 @@ function makePromise(observable: Observable): Promise { }); } -function execute( +export function execute( link: ApolloLink, operation: GraphQLRequest, ): Observable { diff --git a/src/stitching/makeRemoteExecutableSchema.ts b/src/stitching/makeRemoteExecutableSchema.ts index 28ee534dbb4..ea7038b8506 100644 --- a/src/stitching/makeRemoteExecutableSchema.ts +++ b/src/stitching/makeRemoteExecutableSchema.ts @@ -1,6 +1,3 @@ -import { printSchema, Kind, ValueNode } from 'graphql'; -import linkToFetcher from './linkToFetcher'; - // This import doesn't actually import code - only the types. // Don't use ApolloLink to actually construct a link here. import { ApolloLink } from 'apollo-link'; @@ -20,13 +17,26 @@ import { ExecutionResult, print, buildSchema, + printSchema, + Kind, + ValueNode, + GraphQLResolveInfo, } from 'graphql'; +import linkToFetcher, { execute } from './linkToFetcher'; import isEmptyObject from '../isEmptyObject'; import { IResolvers, IResolverObject } from '../Interfaces'; import { makeExecutableSchema } from '../schemaGenerator'; import resolveParentFromTypename from './resolveFromParentTypename'; import defaultMergedResolver from './defaultMergedResolver'; import { checkResultAndHandleErrors } from './errors'; +import { PubSub, PubSubEngine } from 'graphql-subscriptions'; + +export type ResolverFn = ( + rootValue?: any, + args?: any, + context?: any, + info?: GraphQLResolveInfo, +) => AsyncIterator; export type Fetcher = (operation: FetcherOperation) => Promise; @@ -41,10 +51,12 @@ export default function makeRemoteExecutableSchema({ schema, link, fetcher, + createPubSub, }: { schema: GraphQLSchema | string; link?: ApolloLink; fetcher?: Fetcher; + createPubSub?: () => PubSubEngine; }): GraphQLSchema { if (!fetcher && link) { fetcher = linkToFetcher(link); @@ -59,13 +71,16 @@ export default function makeRemoteExecutableSchema({ typeDefs = printSchema(schema); } + // prepare query resolvers + const queryResolvers: IResolverObject = {}; const queryType = schema.getQueryType(); const queries = queryType.getFields(); - const queryResolvers: IResolverObject = {}; Object.keys(queries).forEach(key => { queryResolvers[key] = createResolver(fetcher); }); - let mutationResolvers: IResolverObject = {}; + + // prepare mutation resolvers + const mutationResolvers: IResolverObject = {}; const mutationType = schema.getMutationType(); if (mutationType) { const mutations = mutationType.getFields(); @@ -74,12 +89,31 @@ export default function makeRemoteExecutableSchema({ }); } + // prepare subscription resolvers + const subscriptionResolvers: IResolverObject = {}; + const subscriptionType = schema.getSubscriptionType(); + if (subscriptionType) { + const pubSub = createPubSub ? createPubSub() : new PubSub(); + const subscriptions = subscriptionType.getFields(); + Object.keys(subscriptions).forEach(key => { + subscriptionResolvers[key] = { + subscribe: createSubscriptionResolver(key, link, pubSub), + }; + }); + } + + // merge resolvers into resolver map const resolvers: IResolvers = { [queryType.name]: queryResolvers }; if (!isEmptyObject(mutationResolvers)) { resolvers[mutationType.name] = mutationResolvers; } + if (!isEmptyObject(subscriptionResolvers)) { + resolvers[subscriptionType.name] = subscriptionResolvers; + } + + // add missing abstract resolvers (scalar, unions, interfaces) const typeMap = schema.getTypeMap(); const types = Object.keys(typeMap).map(name => typeMap[name]); for (const type of types) { @@ -108,7 +142,8 @@ export default function makeRemoteExecutableSchema({ type instanceof GraphQLObjectType && type.name.slice(0, 2) !== '__' && type !== queryType && - type !== mutationType + type !== mutationType && + type !== subscriptionType ) { const resolver = {}; Object.keys(type.getFields()).forEach(field => { @@ -142,6 +177,42 @@ function createResolver(fetcher: Fetcher): GraphQLFieldResolver { }; } +function createSubscriptionResolver( + name: string, + link: ApolloLink, + pubSub: PubSubEngine, +): ResolverFn { + return (root, args, context, info) => { + const fragments = Object.keys(info.fragments).map( + fragment => info.fragments[fragment], + ); + const document = { + kind: Kind.DOCUMENT, + definitions: [info.operation, ...fragments], + }; + + const operation = { + query: document, + variables: info.variableValues, + context: { graphqlContext: context }, + }; + const observable = execute(link, operation); + + const observer = { + next(value: any) { + pubSub.publish(`remote-schema-${name}`, value.data); + }, + error(err: Error) { + pubSub.publish(`remote-schema-${name}`, { errors: [err] }); + }, + }; + + observable.subscribe(observer); + + return pubSub.asyncIterator(`remote-schema-${name}`); + }; +} + function createPassThroughScalar({ name, description, diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 8cbc632001f..c39d772925f 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -1,243 +1,221 @@ import { DocumentNode, GraphQLField, - GraphQLFieldMap, GraphQLInputObjectType, + GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, GraphQLResolveInfo, GraphQLScalarType, GraphQLSchema, - GraphQLType, - buildASTSchema, + GraphQLString, + InlineFragmentNode, + Kind, extendSchema, getNamedType, - isCompositeType, isNamedType, parse, } from 'graphql'; -import TypeRegistry from './TypeRegistry'; -import { IResolvers, MergeInfo, IFieldResolver } from '../Interfaces'; -import isEmptyObject from '../isEmptyObject'; +import { + IResolvers, + MergeInfo, + IFieldResolver, + VisitType, + MergeTypeCandidate, + TypeWithResolvers, + VisitTypeResult, +} from '../Interfaces'; import { extractExtensionDefinitions, addResolveFunctionsToSchema, } from '../schemaGenerator'; import { - recreateCompositeType, + recreateType, fieldMapToFieldConfigMap, + createResolveType, } from './schemaRecreation'; import delegateToSchema from './delegateToSchema'; -import typeFromAST from './typeFromAST'; - -const backcompatOptions = { commentDescriptions: true }; +import typeFromAST, { GetType } from './typeFromAST'; +import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; export default function mergeSchemas({ schemas, - onTypeConflict, + visitType, resolvers, }: { - schemas: Array; - onTypeConflict?: ( - left: GraphQLNamedType, - right: GraphQLNamedType, - ) => GraphQLNamedType; + schemas: Array<{ name: string; schema: string | GraphQLSchema }>; + visitType?: VisitType; resolvers?: IResolvers | ((mergeInfo: MergeInfo) => IResolvers); }): GraphQLSchema { - if (!onTypeConflict) { - onTypeConflict = defaultOnTypeConflict; + const allSchemas: { [name: string]: GraphQLSchema } = {}; + const typeCandidates: { [name: string]: Array } = {}; + const types: { [name: string]: GraphQLNamedType } = {}; + const extensions: Array = []; + const fragments = {}; + + if (!resolvers) { + resolvers = {}; } - let queryFields: GraphQLFieldMap = {}; - let mutationFields: GraphQLFieldMap = {}; - let subscriptionFields: GraphQLFieldMap = {}; - const typeRegistry = new TypeRegistry(); + if (!visitType) { + visitType = defaultVisitType; + } - const mergeInfo: MergeInfo = createMergeInfo(typeRegistry); + const resolveType = createResolveType(name => { + if (types[name] === undefined) { + throw new Error(`Can't find type ${name}.`); + } + return types[name]; + }); - const actualSchemas: Array = []; - const typeFragments: Array = []; - const extensions: Array = []; - let fullResolvers: IResolvers = {}; + const createNamedStub: GetType = (name, type) => { + let constructor: any; + if (type === 'object') { + constructor = GraphQLObjectType; + } else if (type === 'interface') { + constructor = GraphQLInterfaceType; + } else { + constructor = GraphQLInputObjectType; + } + return new constructor({ + name, + fields: { + __fake: { + type: GraphQLString, + }, + }, + }); + }; - schemas.forEach(schema => { - if (schema instanceof GraphQLSchema) { - actualSchemas.push(schema); - } else if (typeof schema === 'string') { - let parsedSchemaDocument = parse(schema); - try { - // TODO fix types https://github.com/apollographql/graphql-tools/issues/542 - const actualSchema = (buildASTSchema as any)( - parsedSchemaDocument, - backcompatOptions, - ); - actualSchemas.push(actualSchema); - } catch (e) { - typeFragments.push(parsedSchemaDocument); + schemas.forEach(subSchema => { + if (subSchema.schema instanceof GraphQLSchema) { + const schema = subSchema.schema; + allSchemas[subSchema.name] = schema; + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType(); + const subscriptionType = schema.getSubscriptionType(); + addTypeCandidate(typeCandidates, 'Query', { + schemaName: subSchema.name, + schema, + type: queryType, + }); + if (mutationType) { + addTypeCandidate(typeCandidates, 'Mutation', { + schemaName: subSchema.name, + schema, + type: mutationType, + }); } - parsedSchemaDocument = extractExtensionDefinitions(parsedSchemaDocument); - if (parsedSchemaDocument.definitions.length > 0) { - extensions.push(parsedSchemaDocument); + if (subscriptionType) { + addTypeCandidate(typeCandidates, 'Subscription', { + schemaName: subSchema.name, + schema, + type: subscriptionType, + }); } - } - }); - - actualSchemas.forEach(schema => { - typeRegistry.addSchema(schema); - const queryType = schema.getQueryType(); - const mutationType = schema.getMutationType(); - const subscriptionType = schema.getSubscriptionType(); - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).forEach(typeName => { - const type: GraphQLType = typeMap[typeName]; - if ( - isNamedType(type) && - getNamedType(type).name.slice(0, 2) !== '__' && - type !== queryType && - type !== mutationType && - type !== subscriptionType - ) { - let newType; - if (isCompositeType(type) || type instanceof GraphQLInputObjectType) { - newType = recreateCompositeType(schema, type, typeRegistry); - } else { - newType = getNamedType(type); + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach(typeName => { + const type: GraphQLNamedType = typeMap[typeName]; + if ( + isNamedType(type) && + getNamedType(type).name.slice(0, 2) !== '__' && + type !== queryType && + type !== mutationType && + type !== subscriptionType + ) { + addTypeCandidate(typeCandidates, type.name, { + schemaName: subSchema.name, + schema, + type: type, + }); } - if (newType instanceof GraphQLObjectType) { - delete newType.isTypeOf; + }); + } else if (typeof subSchema.schema === 'string') { + let parsedSchemaDocument = parse(subSchema.schema); + parsedSchemaDocument.definitions.forEach(def => { + const type = typeFromAST(def, createNamedStub); + if (type) { + addTypeCandidate(typeCandidates, type.name, { + schemaName: subSchema.name, + type: type, + }); } - typeRegistry.addType(newType.name, newType, onTypeConflict); - } - }); + }); - Object.keys(queryType.getFields()).forEach(name => { - if (!fullResolvers.Query) { - fullResolvers.Query = {}; - } - fullResolvers.Query[name] = createDelegatingResolver( - mergeInfo, - 'query', - name, + const extensionsDocument = extractExtensionDefinitions( + parsedSchemaDocument, ); - }); - - queryFields = { - ...queryFields, - ...queryType.getFields(), - }; - - if (mutationType) { - if (!fullResolvers.Mutation) { - fullResolvers.Mutation = {}; + if (extensionsDocument.definitions.length > 0) { + extensions.push(extensionsDocument); } - Object.keys(mutationType.getFields()).forEach(name => { - fullResolvers.Mutation[name] = createDelegatingResolver( - mergeInfo, - 'mutation', - name, - ); - }); - - mutationFields = { - ...mutationFields, - ...mutationType.getFields(), - }; + } else { + throw new Error(`Invalid schema ${subSchema.name}`); } + }); - if (subscriptionType) { - if (!fullResolvers.Subscription) { - fullResolvers.Subscription = {}; - } - Object.keys(subscriptionType.getFields()).forEach(name => { - fullResolvers.Subscription[name] = { - subscribe: createDelegatingResolver(mergeInfo, 'subscription', name), - }; - }); + let generatedResolvers = {}; - subscriptionFields = { - ...subscriptionFields, - ...subscriptionType.getFields(), - }; + Object.keys(typeCandidates).forEach(typeName => { + const resultType: VisitTypeResult = visitType( + typeName, + typeCandidates[typeName], + ); + if (resultType === null) { + types[typeName] = null; + } else { + let type: GraphQLNamedType; + let typeResolvers: IResolvers; + if (isNamedType(resultType)) { + type = resultType; + } else if ((resultType).type) { + type = (resultType).type; + typeResolvers = (resultType).resolvers; + } else { + throw new Error('Invalid `visitType` result for type "${typeName}"'); + } + types[typeName] = recreateType(type, resolveType); + if (typeResolvers) { + generatedResolvers[typeName] = typeResolvers; + } } }); - typeFragments.forEach(document => { - document.definitions.forEach(def => { - const type = typeFromAST(typeRegistry, def); - if (type) { - typeRegistry.addType(type.name, type, onTypeConflict); - } - }); + let mergedSchema = new GraphQLSchema({ + query: types.Query as GraphQLObjectType, + mutation: types.Mutation as GraphQLObjectType, + subscription: types.Subscription as GraphQLObjectType, + types: Object.keys(types).map(key => types[key]), }); - let passedResolvers = {}; - if (resolvers) { - if (typeof resolvers === 'function') { - passedResolvers = resolvers(mergeInfo); - } else { - passedResolvers = { ...resolvers }; - } - } + extensions.forEach(extension => { + mergedSchema = (extendSchema as any)(mergedSchema, extension, { + commentDescriptions: true, + }); + }); - Object.keys(passedResolvers).forEach(typeName => { - const type = passedResolvers[typeName]; + Object.keys(resolvers).forEach(typeName => { + const type = resolvers[typeName]; if (type instanceof GraphQLScalarType) { return; } Object.keys(type).forEach(fieldName => { const field = type[fieldName]; if (field.fragment) { - typeRegistry.addFragment(typeName, fieldName, field.fragment); + fragments[typeName] = fragments[typeName] || {}; + fragments[typeName][fieldName] = parseFragmentToInlineFragment( + field.fragment, + ); } }); }); - fullResolvers = mergeDeep(fullResolvers, passedResolvers); - - const query = new GraphQLObjectType({ - name: 'Query', - fields: () => fieldMapToFieldConfigMap(queryFields, typeRegistry), - }); - - let mutation; - if (!isEmptyObject(mutationFields)) { - mutation = new GraphQLObjectType({ - name: 'Mutation', - fields: () => fieldMapToFieldConfigMap(mutationFields, typeRegistry), - }); - } - - let subscription; - if (!isEmptyObject(subscriptionFields)) { - subscription = new GraphQLObjectType({ - name: 'Subscription', - fields: () => fieldMapToFieldConfigMap(subscriptionFields, typeRegistry), - }); - } - - typeRegistry.addType('Query', query); - typeRegistry.addType('Mutation', mutation); - typeRegistry.addType('Subscription', subscription); - - let mergedSchema = new GraphQLSchema({ - query, - mutation, - subscription, - types: typeRegistry.getAllTypes(), - }); - - extensions.forEach(extension => { - // TODO fix types https://github.com/apollographql/graphql-tools/issues/542 - mergedSchema = (extendSchema as any)( - mergedSchema, - extension, - backcompatOptions, - ); - }); - - addResolveFunctionsToSchema(mergedSchema, fullResolvers); + addResolveFunctionsToSchema( + mergedSchema, + mergeDeep(generatedResolvers, resolvers), + ); + const mergeInfo = createMergeInfo(allSchemas, fragments); forEachField(mergedSchema, field => { if (field.resolve) { const fieldResolver = field.resolve; @@ -246,57 +224,102 @@ export default function mergeSchemas({ return fieldResolver(parent, args, context, newInfo); }; } + if (field.subscribe) { + const fieldResolver = field.subscribe; + field.subscribe = (parent, args, context, info) => { + const newInfo = { ...info, mergeInfo }; + return fieldResolver(parent, args, context, newInfo); + }; + } }); return mergedSchema; } -function defaultOnTypeConflict( - left: GraphQLNamedType, - right: GraphQLNamedType, -): GraphQLNamedType { - return left; -} - -function createMergeInfo(typeRegistry: TypeRegistry): MergeInfo { +function createMergeInfo( + schemas: { [name: string]: GraphQLSchema }, + fragmentReplacements: { + [name: string]: { [fieldName: string]: InlineFragmentNode }; + }, +): MergeInfo { return { + getSubSchema(schemaName: string): GraphQLSchema { + const schema = schemas[schemaName]; + if (!schema) { + throw new Error(`No subschema named ${schemaName}.`); + } + return schema; + }, delegate( + schemaName: string, operation: 'query' | 'mutation' | 'subscription', fieldName: string, args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, ): any { - const schema = typeRegistry.getSchemaByField(operation, fieldName); + const schema = schemas[schemaName]; + const fragmentTransform = ReplaceFieldWithFragment( + schema, + fragmentReplacements, + ); if (!schema) { - throw new Error( - `Cannot find subschema for root field ${operation}.${fieldName}`, - ); + throw new Error(`No subschema named ${schemaName}.`); } - const fragmentReplacements = typeRegistry.fragmentReplacements; return delegateToSchema( schema, - fragmentReplacements, operation, fieldName, args, context, info, + [fragmentTransform], ); }, }; } function createDelegatingResolver( - mergeInfo: MergeInfo, + schemaName: string, operation: 'query' | 'mutation' | 'subscription', fieldName: string, ): IFieldResolver { return (root, args, context, info) => { - return mergeInfo.delegate(operation, fieldName, args, context, info); + return info.mergeInfo.delegate( + schemaName, + operation, + fieldName, + args, + context, + info, + ); }; } +type FieldIteratorFn = ( + fieldDef: GraphQLField, + typeName: string, + fieldName: string, +) => void; + +function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach(typeName => { + const type = typeMap[typeName]; + + if ( + !getNamedType(type).name.startsWith('__') && + type instanceof GraphQLObjectType + ) { + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + fn(field, typeName, fieldName); + }); + } + }); +} + function isObject(item: any): Boolean { return item && typeof item === 'object' && !Array.isArray(item); } @@ -319,26 +342,79 @@ function mergeDeep(target: any, source: any): any { return output; } -type FieldIteratorFn = ( - fieldDef: GraphQLField, - typeName: string, - fieldName: string, -) => void; +function parseFragmentToInlineFragment( + definitions: string, +): InlineFragmentNode { + const document = parse(definitions); + for (const definition of document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition: definition.typeCondition, + selectionSet: definition.selectionSet, + }; + } + } + throw new Error('Could not parse fragment'); +} -function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).forEach(typeName => { - const type = typeMap[typeName]; +function addTypeCandidate( + typeCandidates: { [name: string]: Array }, + name: string, + typeCandidate: MergeTypeCandidate, +) { + if (!typeCandidates[name]) { + typeCandidates[name] = []; + } + typeCandidates[name].push(typeCandidate); +} - if ( - !getNamedType(type).name.startsWith('__') && - type instanceof GraphQLObjectType - ) { - const fields = type.getFields(); - Object.keys(fields).forEach(fieldName => { - const field = fields[fieldName]; - fn(field, typeName, fieldName); - }); +const defaultVisitType: VisitType = ( + name: string, + candidates: Array, +) => { + const resolveType = createResolveType((_, type) => type); + if (name === 'Query' || name === 'Mutation' || name === 'Subscription') { + let fields = {}; + let operationName: 'query' | 'mutation' | 'subscription'; + switch (name) { + case 'Query': + operationName = 'query'; + break; + case 'Mutation': + operationName = 'mutation'; + break; + case 'Subscription': + operationName = 'subscription'; + break; + default: + break; } - }); -} + const resolvers = {}; + const resolverKey = + operationName === 'subscription' ? 'subscribe' : 'resolve'; + candidates.forEach(({ type: candidateType, schemaName }) => { + const candidateFields = (candidateType as GraphQLObjectType).getFields(); + fields = { ...fields, ...candidateFields }; + Object.keys(candidateFields).forEach(fieldName => { + resolvers[fieldName] = { + [resolverKey]: createDelegatingResolver( + schemaName, + operationName, + fieldName, + ), + }; + }); + }); + const type = new GraphQLObjectType({ + name, + fields: fieldMapToFieldConfigMap(fields, resolveType), + }); + return { + type, + resolvers, + }; + } else { + return candidates[candidates.length - 1].type; + } +}; diff --git a/src/stitching/resolvers.ts b/src/stitching/resolvers.ts new file mode 100644 index 00000000000..fc89a9e0955 --- /dev/null +++ b/src/stitching/resolvers.ts @@ -0,0 +1,103 @@ +import { + GraphQLSchema, + GraphQLFieldResolver, + GraphQLObjectType, +} from 'graphql'; +import { IResolvers, Operation } from '../Interfaces'; +import delegateToSchema from './delegateToSchema'; +import { Transform } from '../transforms/index'; + +export type Mapping = { + [typeName: string]: { + [fieldName: string]: { + name: string; + operation: Operation; + }; + }; +}; + +export function generateProxyingResolvers( + targetSchema: GraphQLSchema, + transforms: Array, + mapping: Mapping, +): IResolvers { + const result = {}; + Object.keys(mapping).forEach(name => { + result[name] = {}; + const innerMapping = mapping[name]; + Object.keys(innerMapping).forEach(from => { + const to = innerMapping[from]; + const resolverType = + to.operation === 'subscription' ? 'subscribe' : 'resolve'; + result[name][from] = { + [resolverType]: createProxyingResolver( + targetSchema, + to.operation, + to.name, + transforms, + ), + }; + }); + }); + return result; +} + +export function generateSimpleMapping(targetSchema: GraphQLSchema): Mapping { + const query = targetSchema.getQueryType(); + const mutation = targetSchema.getMutationType(); + const subscription = targetSchema.getSubscriptionType(); + + const result: Mapping = {}; + if (query) { + result[query.name] = generateMappingFromObjectType(query, 'query'); + } + if (mutation) { + result[mutation.name] = generateMappingFromObjectType(mutation, 'mutation'); + } + if (subscription) { + result[subscription.name] = generateMappingFromObjectType( + subscription, + 'subscription', + ); + } + + return result; +} + +function generateMappingFromObjectType( + type: GraphQLObjectType, + operation: Operation, +): { + [fieldName: string]: { + name: string; + operation: Operation; + }; +} { + const result = {}; + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + result[fieldName] = { + name: fieldName, + operation, + }; + }); + return result; +} + +function createProxyingResolver( + targetSchema: GraphQLSchema, + targetOperation: Operation, + targetField: string, + transforms: Array, +): GraphQLFieldResolver { + return (parent, args, context, info) => + delegateToSchema( + targetSchema, + targetOperation, + targetField, + {}, + context, + info, + transforms, + ); +} diff --git a/src/stitching/schemaRecreation.ts b/src/stitching/schemaRecreation.ts index 0e4e3983850..f08f48a41c0 100644 --- a/src/stitching/schemaRecreation.ts +++ b/src/stitching/schemaRecreation.ts @@ -1,31 +1,43 @@ import { GraphQLArgument, GraphQLArgumentConfig, - GraphQLCompositeType, + GraphQLBoolean, + GraphQLEnumType, GraphQLField, GraphQLFieldConfig, GraphQLFieldConfigArgumentMap, GraphQLFieldConfigMap, GraphQLFieldMap, + GraphQLFloat, + GraphQLID, GraphQLInputField, GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, GraphQLInputFieldMap, GraphQLInputObjectType, + GraphQLInt, GraphQLInterfaceType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, GraphQLObjectType, - GraphQLSchema, + GraphQLScalarType, + GraphQLString, + GraphQLType, GraphQLUnionType, + Kind, + ValueNode, + getNamedType, + isNamedType, } from 'graphql'; -import TypeRegistry from './TypeRegistry'; +import { ResolveType } from '../Interfaces'; import resolveFromParentTypename from './resolveFromParentTypename'; import defaultMergedResolver from './defaultMergedResolver'; -export function recreateCompositeType( - schema: GraphQLSchema, - type: GraphQLCompositeType | GraphQLInputObjectType, - registry: TypeRegistry, -): GraphQLCompositeType | GraphQLInputObjectType { +export function recreateType( + type: GraphQLNamedType, + resolveType: ResolveType, +): GraphQLNamedType { if (type instanceof GraphQLObjectType) { const fields = type.getFields(); const interfaces = type.getInterfaces(); @@ -33,9 +45,9 @@ export function recreateCompositeType( return new GraphQLObjectType({ name: type.name, description: type.description, - isTypeOf: type.isTypeOf, - fields: () => fieldMapToFieldConfigMap(fields, registry), - interfaces: () => interfaces.map(iface => registry.resolveType(iface)), + astNode: type.astNode, + fields: () => fieldMapToFieldConfigMap(fields, resolveType), + interfaces: () => interfaces.map(iface => resolveType(iface)), }); } else if (type instanceof GraphQLInterfaceType) { const fields = type.getFields(); @@ -43,7 +55,8 @@ export function recreateCompositeType( return new GraphQLInterfaceType({ name: type.name, description: type.description, - fields: () => fieldMapToFieldConfigMap(fields, registry), + astNode: type.astNode, + fields: () => fieldMapToFieldConfigMap(fields, resolveType), resolveType: (parent, context, info) => resolveFromParentTypename(parent, info.schema), }); @@ -51,8 +64,9 @@ export function recreateCompositeType( return new GraphQLUnionType({ name: type.name, description: type.description, - types: () => - type.getTypes().map(unionMember => registry.resolveType(unionMember)), + astNode: type.astNode, + + types: () => type.getTypes().map(unionMember => resolveType(unionMember)), resolveType: (parent, context, info) => resolveFromParentTypename(parent, info.schema), }); @@ -60,44 +74,142 @@ export function recreateCompositeType( return new GraphQLInputObjectType({ name: type.name, description: type.description, - fields: () => inputFieldMapToFieldConfigMap(type.getFields(), registry), + astNode: type.astNode, + + fields: () => + inputFieldMapToFieldConfigMap(type.getFields(), resolveType), }); + } else if (type instanceof GraphQLEnumType) { + const values = type.getValues(); + const newValues = {}; + values.forEach(value => { + newValues[value.name] = { value: value.name }; + }); + return new GraphQLEnumType({ + name: type.name, + description: type.description, + astNode: type.astNode, + values: newValues, + }); + } else if (type instanceof GraphQLScalarType) { + if ( + type === GraphQLID || + type === GraphQLString || + type === GraphQLFloat || + type === GraphQLBoolean || + type === GraphQLInt + ) { + return type; + } else { + return new GraphQLScalarType({ + name: type.name, + description: type.description, + astNode: type.astNode, + serialize(value: any) { + return value; + }, + parseValue(value: any) { + return value; + }, + parseLiteral(ast: ValueNode) { + return parseLiteral(ast); + }, + }); + } } else { throw new Error(`Invalid type ${type}`); } } +function parseLiteral(ast: ValueNode): any { + switch (ast.kind) { + case Kind.STRING: + case Kind.BOOLEAN: { + return ast.value; + } + case Kind.INT: + case Kind.FLOAT: { + return parseFloat(ast.value); + } + case Kind.OBJECT: { + const value = Object.create(null); + ast.fields.forEach(field => { + value[field.name.value] = parseLiteral(field.value); + }); + + return value; + } + case Kind.LIST: { + return ast.values.map(parseLiteral); + } + default: + return null; + } +} + export function fieldMapToFieldConfigMap( fields: GraphQLFieldMap, - registry: TypeRegistry, + resolveType: ResolveType, ): GraphQLFieldConfigMap { const result: GraphQLFieldConfigMap = {}; Object.keys(fields).forEach(name => { - result[name] = fieldToFieldConfig(fields[name], registry); + const field = fields[name]; + const type = resolveType(field.type); + if (type !== null) { + result[name] = fieldToFieldConfig(fields[name], resolveType); + } }); return result; } +export function createResolveType( + getType: (name: string, type: GraphQLType) => GraphQLType | null, +): ResolveType { + const resolveType = (type: T): T => { + if (type instanceof GraphQLList) { + const innerType = resolveType(type.ofType); + if (innerType === null) { + return null; + } else { + return new GraphQLList(innerType) as T; + } + } else if (type instanceof GraphQLNonNull) { + const innerType = resolveType(type.ofType); + if (innerType === null) { + return null; + } else { + return new GraphQLNonNull(innerType) as T; + } + } else if (isNamedType(type)) { + return getType(getNamedType(type).name, type) as T; + } else { + return type; + } + }; + return resolveType; +} + function fieldToFieldConfig( field: GraphQLField, - registry: TypeRegistry, + resolveType: ResolveType, ): GraphQLFieldConfig { return { - type: registry.resolveType(field.type), - args: argsToFieldConfigArgumentMap(field.args, registry), + type: resolveType(field.type), + args: argsToFieldConfigArgumentMap(field.args, resolveType), resolve: defaultMergedResolver, description: field.description, deprecationReason: field.deprecationReason, + astNode: field.astNode, }; } function argsToFieldConfigArgumentMap( args: Array, - registry: TypeRegistry, + resolveType: ResolveType, ): GraphQLFieldConfigArgumentMap { const result: GraphQLFieldConfigArgumentMap = {}; args.forEach(arg => { - const [name, def] = argumentToArgumentConfig(arg, registry); + const [name, def] = argumentToArgumentConfig(arg, resolveType); result[name] = def; }); return result; @@ -105,12 +217,12 @@ function argsToFieldConfigArgumentMap( function argumentToArgumentConfig( argument: GraphQLArgument, - registry: TypeRegistry, + resolveType: ResolveType, ): [string, GraphQLArgumentConfig] { return [ argument.name, { - type: registry.resolveType(argument.type), + type: resolveType(argument.type), defaultValue: argument.defaultValue, description: argument.description, }, @@ -119,22 +231,27 @@ function argumentToArgumentConfig( function inputFieldMapToFieldConfigMap( fields: GraphQLInputFieldMap, - registry: TypeRegistry, + resolveType: ResolveType, ): GraphQLInputFieldConfigMap { const result: GraphQLInputFieldConfigMap = {}; Object.keys(fields).forEach(name => { - result[name] = inputFieldToFieldConfig(fields[name], registry); + const field = fields[name]; + const type = resolveType(field.type); + if (type !== null) { + result[name] = inputFieldToFieldConfig(fields[name], resolveType); + } }); return result; } function inputFieldToFieldConfig( field: GraphQLInputField, - registry: TypeRegistry, + resolveType: ResolveType, ): GraphQLInputFieldConfig { return { - type: registry.resolveType(field.type), + type: resolveType(field.type), defaultValue: field.defaultValue, description: field.description, + astNode: field.astNode, }; } diff --git a/src/stitching/typeFromAST.ts b/src/stitching/typeFromAST.ts index 63458ab7a2d..ebeb30bea9d 100644 --- a/src/stitching/typeFromAST.ts +++ b/src/stitching/typeFromAST.ts @@ -23,60 +23,60 @@ import { UnionTypeDefinitionNode, valueFromAST, } from 'graphql'; -// -// TODO put back import once PR is merged -// https://github.com/graphql/graphql-js/pull/1165 -// import { getDescription } from 'graphql/utilities/buildASTSchema'; +import resolveFromParentType from './resolveFromParentTypename'; const backcompatOptions = { commentDescriptions: true }; -import resolveFromParentType from './resolveFromParentTypename'; -import TypeRegistry from './TypeRegistry'; +export type GetType = ( + name: string, + // this is a hack + type: 'object' | 'interface' | 'input', +) => GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType; export default function typeFromAST( - typeRegistry: TypeRegistry, node: DefinitionNode, + getType: GetType, ): GraphQLNamedType | null { switch (node.kind) { case Kind.OBJECT_TYPE_DEFINITION: - return makeObjectType(typeRegistry, node); + return makeObjectType(node, getType); case Kind.INTERFACE_TYPE_DEFINITION: - return makeInterfaceType(typeRegistry, node); + return makeInterfaceType(node, getType); case Kind.ENUM_TYPE_DEFINITION: - return makeEnumType(typeRegistry, node); + return makeEnumType(node, getType); case Kind.UNION_TYPE_DEFINITION: - return makeUnionType(typeRegistry, node); + return makeUnionType(node, getType); case Kind.SCALAR_TYPE_DEFINITION: - return makeScalarType(typeRegistry, node); + return makeScalarType(node, getType); case Kind.INPUT_OBJECT_TYPE_DEFINITION: - return makeInputObjectType(typeRegistry, node); + return makeInputObjectType(node, getType); default: return null; } } function makeObjectType( - typeRegistry: TypeRegistry, node: ObjectTypeDefinitionNode, + getType: GetType, ): GraphQLObjectType { return new GraphQLObjectType({ name: node.name.value, - fields: () => makeFields(typeRegistry, node.fields), + fields: () => makeFields(node.fields, getType), interfaces: () => node.interfaces.map( - iface => typeRegistry.getType(iface.name.value) as GraphQLInterfaceType, + iface => getType(iface.name.value, 'interface') as GraphQLInterfaceType, ), description: getDescription(node, backcompatOptions), }); } function makeInterfaceType( - typeRegistry: TypeRegistry, node: InterfaceTypeDefinitionNode, + getType: GetType, ): GraphQLInterfaceType { return new GraphQLInterfaceType({ name: node.name.value, - fields: () => makeFields(typeRegistry, node.fields), + fields: () => makeFields(node.fields, getType), description: getDescription(node, backcompatOptions), resolveType: (parent, context, info) => resolveFromParentType(parent, info.schema), @@ -84,8 +84,8 @@ function makeInterfaceType( } function makeEnumType( - typeRegistry: TypeRegistry, node: EnumTypeDefinitionNode, + getType: GetType, ): GraphQLEnumType { const values = {}; node.values.forEach(value => { @@ -101,14 +101,14 @@ function makeEnumType( } function makeUnionType( - typeRegistry: TypeRegistry, node: UnionTypeDefinitionNode, + getType: GetType, ): GraphQLUnionType { return new GraphQLUnionType({ name: node.name.value, types: () => node.types.map( - type => resolveType(typeRegistry, type) as GraphQLObjectType, + type => resolveType(type, getType, 'object') as GraphQLObjectType, ), description: getDescription(node, backcompatOptions), resolveType: (parent, context, info) => @@ -117,8 +117,8 @@ function makeUnionType( } function makeScalarType( - typeRegistry: TypeRegistry, node: ScalarTypeDefinitionNode, + getType: GetType, ): GraphQLScalarType { return new GraphQLScalarType({ name: node.name.value, @@ -134,38 +134,32 @@ function makeScalarType( } function makeInputObjectType( - typeRegistry: TypeRegistry, node: InputObjectTypeDefinitionNode, + getType: GetType, ): GraphQLInputObjectType { return new GraphQLInputObjectType({ name: node.name.value, - fields: () => makeValues(typeRegistry, node.fields), + fields: () => makeValues(node.fields, getType), description: getDescription(node, backcompatOptions), }); } -function makeFields( - typeRegistry: TypeRegistry, - nodes: Array, -) { +function makeFields(nodes: Array, getType: GetType) { const result = {}; nodes.forEach(node => { result[node.name.value] = { - type: resolveType(typeRegistry, node.type), - args: makeValues(typeRegistry, node.arguments), + type: resolveType(node.type, getType, 'object'), + args: makeValues(node.arguments, getType), description: getDescription(node, backcompatOptions), }; }); return result; } -function makeValues( - typeRegistry: TypeRegistry, - nodes: Array, -) { +function makeValues(nodes: Array, getType: GetType) { const result = {}; nodes.forEach(node => { - const type = resolveType(typeRegistry, node.type) as GraphQLInputType; + const type = resolveType(node.type, getType, 'input') as GraphQLInputType; result[node.name.value] = { type, defaultValue: valueFromAST(node.defaultValue, type), @@ -175,14 +169,18 @@ function makeValues( return result; } -function resolveType(typeRegistry: TypeRegistry, node: TypeNode): GraphQLType { +function resolveType( + node: TypeNode, + getType: GetType, + type: 'object' | 'interface' | 'input', +): GraphQLType { switch (node.kind) { case Kind.LIST_TYPE: - return new GraphQLList(resolveType(typeRegistry, node.type)); + return new GraphQLList(resolveType(node.type, getType, type)); case Kind.NON_NULL_TYPE: - return new GraphQLNonNull(resolveType(typeRegistry, node.type)); + return new GraphQLNonNull(resolveType(node.type, getType, type)); default: - return typeRegistry.getType(node.name.value); + return getType(node.name.value, type); } } diff --git a/src/test/testMakeRemoteExecutableSchema.ts b/src/test/testMakeRemoteExecutableSchema.ts new file mode 100644 index 00000000000..7eb79dc6d05 --- /dev/null +++ b/src/test/testMakeRemoteExecutableSchema.ts @@ -0,0 +1,48 @@ +/* tslint:disable:no-unused-expression */ + +import { expect } from 'chai'; +import { forAwaitEach } from 'iterall'; +import { GraphQLSchema, ExecutionResult, subscribe, parse } from 'graphql'; +import { + subscriptionSchema, + subscriptionPubSubTrigger, + subscriptionPubSub, + makeSchemaRemoteFromLink, +} from '../test/testingSchemas'; + +describe('remote subscriptions', () => { + let schema: GraphQLSchema; + before(async () => { + schema = await makeSchemaRemoteFromLink(subscriptionSchema); + }); + + it('should work', done => { + const mockNotification = { + notifications: { + text: 'Hello world', + }, + }; + + const subscription = parse(` + subscription Subscription { + notifications { + text + } + } + `); + + let notificationCnt = 0; + subscribe(schema, subscription).then(results => + forAwaitEach( + results as AsyncIterable, + (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockNotification); + !notificationCnt++ ? done() : null; + }, + ), + ); + + subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification); + }); +}); diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 01df3ef40a7..a055dafa458 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -4,19 +4,21 @@ import { expect } from 'chai'; import { graphql, GraphQLSchema, - GraphQLScalarType, GraphQLObjectType, subscribe, parse, ExecutionResult, } from 'graphql'; +import { VisitType } from '../Interfaces'; import mergeSchemas from '../stitching/mergeSchemas'; import { propertySchema as localPropertySchema, + productSchema as localProductSchema, bookingSchema as localBookingSchema, subscriptionSchema as localSubscriptionSchema, remoteBookingSchema, remotePropertySchema, + remoteProductSchema, subscriptionPubSub, subscriptionPubSubTrigger, } from './testingSchemas'; @@ -24,27 +26,40 @@ import { forAwaitEach } from 'iterall'; import { makeExecutableSchema } from '../schemaGenerator'; const testCombinations = [ - { name: 'local', booking: localBookingSchema, property: localPropertySchema }, + { + name: 'local', + booking: localBookingSchema, + property: localPropertySchema, + product: localProductSchema, + }, { name: 'remote', booking: remoteBookingSchema, property: remotePropertySchema, + product: remoteProductSchema, }, { name: 'hybrid', booking: localBookingSchema, property: remotePropertySchema, + product: localProductSchema, }, ]; -const scalarTest = ` - # Description of TestScalar. +let scalarTest = ` + """ + Description of TestScalar. + """ scalar TestScalar - # Description of AnotherNewScalar. + """ + Description of AnotherNewScalar. + """ scalar AnotherNewScalar - # A type that uses TestScalar. + """ + A type that uses TestScalar. + """ type TestingScalar { value: TestScalar } @@ -54,31 +69,60 @@ const scalarTest = ` } `; -const enumTest = ` -# A type that uses an Enum. -enum Color { - RED -} +let enumTest = ` + """ + A type that uses an Enum. + """ + enum Color { + RED + } -schema { - query: Query -} + """ + A type that uses an Enum with a numeric constant. + """ + enum NumericEnum { + TEST + } -type Query { - color: Color -} + schema { + query: Query + } + + type Query { + color: Color + numericEnum: NumericEnum + } `; -let graphql11compat = ''; -if (process.env.GRAPHQL_VERSION === '^0.11') { - graphql11compat = '{}'; -} +const enumSchema = makeExecutableSchema({ + typeDefs: enumTest, + resolvers: { + Color: { + RED: '#EA3232', + }, + NumericEnum: { + TEST: 1, + }, + Query: { + color() { + return '#EA3232'; + }, + numericEnum() { + return 1; + }, + }, + }, +}); -const linkSchema = ` - # A new type linking the Property type. +let linkSchema = ` + """ + A new type linking the Property type. + """ type LinkType { test: String - # The property. + """ + The property. + """ property: Property } @@ -86,16 +130,21 @@ const linkSchema = ` id: ID! } - extend type Booking implements Node { - # The property of the booking. + """ + The property of the booking. + """ property: Property } extend type Property implements Node { - # A list of bookings. + """ + A list of bookings. + """ bookings( - # The maximum number of bookings to retrieve. + """ + The maximum number of bookings to retrieve. + """ limit: Int ): [Booking] } @@ -103,50 +152,154 @@ const linkSchema = ` extend type Query { delegateInterfaceTest: TestInterface delegateArgumentTest(arbitraryArg: Int): Property - # A new field on the root query. + """ + A new field on the root query. + """ linkTest: LinkType node(id: ID!): Node nodes: [Node] } - extend type Customer implements Node ${graphql11compat} + extend type Customer implements Node `; +const loneExtend = ` + extend type Booking { + foo: String! + } +`; + +if (process.env.GRAPHQL_VERSION === '^0.11') { + scalarTest = ` + # Description of TestScalar. + scalar TestScalar + + # Description of AnotherNewScalar. + scalar AnotherNewScalar + + # A type that uses TestScalar. + type TestingScalar { + value: TestScalar + } + + type Query { + testingScalar: TestingScalar + } + `; + + enumTest = ` + # A type that uses an Enum. + enum Color { + RED + } + + # A type that uses an Enum with a numeric constant. + enum NumericEnum { + TEST + } + + schema { + query: Query + } + + type Query { + color: Color + numericEnum: NumericEnum + } + `; + + linkSchema = ` + # A new type linking the Property type. + type LinkType { + test: String + # The property. + property: Property + } + + interface Node { + id: ID! + } + + extend type Booking implements Node { + # The property of the booking. + property: Property + } + + extend type Property implements Node { + # A list of bookings. + bookings( + # The maximum number of bookings to retrieve. + limit: Int + ): [Booking] + } + + extend type Query { + delegateInterfaceTest: TestInterface + delegateArgumentTest(arbitraryArg: Int): Property + # A new field on the root query. + linkTest: LinkType + node(id: ID!): Node + nodes: [Node] + } + + extend type Customer implements Node {} + `; +} + testCombinations.forEach(async combination => { describe('merging ' + combination.name, () => { let mergedSchema: GraphQLSchema, propertySchema: GraphQLSchema, + productSchema: GraphQLSchema, bookingSchema: GraphQLSchema; before(async () => { propertySchema = await combination.property; bookingSchema = await combination.booking; + productSchema = await combination.product; mergedSchema = mergeSchemas({ schemas: [ - propertySchema, - bookingSchema, - scalarTest, - enumTest, - linkSchema, - localSubscriptionSchema, + { + name: 'Property', + schema: propertySchema, + }, + { + name: 'Booking', + schema: bookingSchema, + }, + { + name: 'Product', + schema: productSchema, + }, + { + name: 'ScalarTest', + schema: scalarTest, + }, + { + name: 'EnumTest', + schema: enumSchema, + }, + { + name: 'LinkSchema', + schema: linkSchema, + }, + { + name: 'LoneExtend', + schema: loneExtend, + }, + { + name: 'LocalSubscription', + schema: localSubscriptionSchema, + }, ], resolvers: { - TestScalar: new GraphQLScalarType({ - name: 'TestScalar', - description: undefined, - serialize: value => value, - parseValue: value => value, - parseLiteral: () => null, - }), - Color: { - RED: '#EA3232', - }, Property: { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { return info.mergeInfo.delegate( + 'Booking', 'query', 'bookingsByPropertyId', { @@ -164,6 +317,7 @@ testCombinations.forEach(async combination => { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { return info.mergeInfo.delegate( + 'Property', 'query', 'propertyById', { @@ -179,6 +333,7 @@ testCombinations.forEach(async combination => { property: { resolve(parent, args, context, info) { return info.mergeInfo.delegate( + 'Property', 'query', 'propertyById', { @@ -191,11 +346,9 @@ testCombinations.forEach(async combination => { }, }, Query: { - color() { - return '#EA3232'; - }, delegateInterfaceTest(parent, args, context, info) { return info.mergeInfo.delegate( + 'Property', 'query', 'interfaceTest', { @@ -207,6 +360,7 @@ testCombinations.forEach(async combination => { }, delegateArgumentTest(parent, args, context, info) { return info.mergeInfo.delegate( + 'Property', 'query', 'propertyById', { @@ -227,6 +381,7 @@ testCombinations.forEach(async combination => { resolve(parent, args, context, info) { if (args.id.startsWith('p')) { return info.mergeInfo.delegate( + 'Property', 'query', 'propertyById', args, @@ -235,6 +390,7 @@ testCombinations.forEach(async combination => { ); } else if (args.id.startsWith('b')) { return info.mergeInfo.delegate( + 'Booking', 'query', 'bookingById', args, @@ -243,6 +399,7 @@ testCombinations.forEach(async combination => { ); } else if (args.id.startsWith('c')) { return info.mergeInfo.delegate( + 'Booking', 'query', 'customerById', args, @@ -256,6 +413,7 @@ testCombinations.forEach(async combination => { }, async nodes(parent, args, context, info) { const bookings = await info.mergeInfo.delegate( + 'Booking', 'query', 'bookings', {}, @@ -263,6 +421,7 @@ testCombinations.forEach(async combination => { info, ); const properties = await info.mergeInfo.delegate( + 'Property', 'query', 'properties', {}, @@ -350,24 +509,12 @@ testCombinations.forEach(async combination => { }); it('works with custom enums', async () => { - const enumSchema = makeExecutableSchema({ - typeDefs: enumTest, - resolvers: { - Color: { - RED: '#EA3232', - }, - Query: { - color() { - return '#EA3232'; - }, - }, - }, - }); const enumResult = await graphql( enumSchema, ` query { color + numericEnum } `, ); @@ -377,6 +524,7 @@ testCombinations.forEach(async combination => { ` query { color + numericEnum } `, ); @@ -384,6 +532,7 @@ testCombinations.forEach(async combination => { expect(enumResult).to.deep.equal({ data: { color: 'RED', + numericEnum: 'TEST', }, }); expect(mergedResult).to.deep.equal(enumResult); @@ -1010,21 +1159,21 @@ bookingById(id: "b1") { describe('variables', () => { it('basic', async () => { const propertyFragment = ` -propertyById(id: $p1) { - id - name -} - `; + propertyById(id: $p1) { + id + name + } + `; const bookingFragment = ` -bookingById(id: $b1) { - id - customer { - name - } - startTime - endTime -} - `; + bookingById(id: $b1) { + id + customer { + name + } + startTime + endTime + } + `; const propertyResult = await graphql( propertySchema, @@ -1447,10 +1596,6 @@ bookingById(id: $b1) { describe('types in schema extensions', () => { it('should parse descriptions on new types', () => { - // Because we redefine it via `GraphQLScalarType` above, it will get - // its description from there. - expect(mergedSchema.getType('TestScalar').description).to.be.undefined; - expect(mergedSchema.getType('AnotherNewScalar').description).to.equal( 'Description of AnotherNewScalar.', ); @@ -1463,6 +1608,10 @@ bookingById(id: $b1) { 'A type that uses an Enum.', ); + expect(mergedSchema.getType('NumericEnum').description).to.equal( + 'A type that uses an Enum with a numeric constant.', + ); + expect(mergedSchema.getType('LinkType').description).to.equal( 'A new type linking the Property type.', ); @@ -1694,6 +1843,39 @@ bookingById(id: $b1) { // }); // }); + it('multi-interface filter', async () => { + const result = await graphql( + mergedSchema, + ` + query { + products { + id + __typename + ... on Sellable { + price + } + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + products: [ + { + id: 'pd1', + __typename: 'SimpleProduct', + price: 100, + }, + { + id: 'pd2', + __typename: 'DownloadableProduct', + }, + ], + }, + }); + }); + it('arbitrary transforms that return interfaces', async () => { const result = await graphql( mergedSchema, @@ -1786,3 +1968,118 @@ bookingById(id: $b1) { }); }); }); + +describe('mergeSchema options', () => { + describe('should filter types', () => { + let schema: GraphQLSchema; + + before(async () => { + const bookingSchema = await remoteBookingSchema; + const createTypeFilteringVisitTypes = ( + typeNames: Array, + ): VisitType => { + return (name, candidates) => { + if ( + ['ID', 'String', 'DateTime'].includes(name) || + typeNames.includes(name) + ) { + return candidates[candidates.length - 1].type; + } else { + return null; + } + }; + }; + schema = mergeSchemas({ + schemas: [ + { + name: 'Booking', + schema: bookingSchema, + }, + { + name: 'Selector', + schema: ` + type Query { + bookingById(id: ID!): Booking + }, + `, + }, + ], + visitType: createTypeFilteringVisitTypes(['Query', 'Booking']), + resolvers: { + Query: { + bookingById(parent, args, context, info) { + return info.mergeInfo.delegate( + 'Booking', + 'query', + 'bookingById', + args, + context, + info, + ); + }, + }, + }, + }); + }); + + it('should work normally', async () => { + const result = await graphql( + schema, + ` + query { + bookingById(id: "b1") { + id + propertyId + startTime + endTime + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + bookingById: { + endTime: '2016-06-03', + id: 'b1', + propertyId: 'p1', + startTime: '2016-05-04', + }, + }, + }); + }); + + it('should error on removed types', async () => { + const result = await graphql( + schema, + ` + query { + bookingById(id: "b1") { + id + propertyId + startTime + endTime + customer { + id + } + } + } + `, + ); + expect(result).to.deep.equal({ + errors: [ + { + locations: [ + { + column: 15, + line: 8, + }, + ], + message: 'Cannot query field "customer" on type "Booking".', + path: undefined, + }, + ], + }); + }); + }); +}); diff --git a/src/test/testSchemaGenerator.ts b/src/test/testSchemaGenerator.ts index f0e1502f6a6..738582dd059 100644 --- a/src/test/testSchemaGenerator.ts +++ b/src/test/testSchemaGenerator.ts @@ -148,8 +148,10 @@ describe('generating schema from shorthand', () => { }); it('can generate a schema', () => { - const shorthand = ` - # A bird species + let shorthand = ` + """ + A bird species + """ type BirdSpecies { name: String!, wingspan: Int @@ -163,6 +165,23 @@ describe('generating schema from shorthand', () => { } `; + if (process.env.GRAPHQL_VERSION === '^0.11') { + shorthand = ` + # A bird species + type BirdSpecies { + name: String!, + wingspan: Int + } + type RootQuery { + species(name: String!): [BirdSpecies] + } + + schema { + query: RootQuery + } + `; + } + const resolve = { RootQuery: { species() { @@ -871,12 +890,17 @@ describe('generating schema from shorthand', () => { RED } + enum NumericEnum { + TEST + } + schema { query: Query } type Query { color: Color + numericEnum: NumericEnum } `; @@ -884,6 +908,9 @@ describe('generating schema from shorthand', () => { Color: { RED: '#EA3232', }, + NumericEnum: { + TEST: 1 + } }; const jsSchema = makeExecutableSchema({ @@ -893,6 +920,7 @@ describe('generating schema from shorthand', () => { expect(jsSchema.getQueryType().name).to.equal('Query'); expect(jsSchema.getType('Color')).to.be.an.instanceof(GraphQLEnumType); + expect(jsSchema.getType('NumericEnum')).to.be.an.instanceof(GraphQLEnumType); }); it('supports passing the value for a GraphQLEnumType in resolveFunctions', () => { @@ -901,27 +929,39 @@ describe('generating schema from shorthand', () => { RED } + enum NumericEnum { + TEST + } + schema { query: Query } type Query { color: Color + numericEnum: NumericEnum } `; const testQuery = `{ color + numericEnum }`; const resolveFunctions = { Color: { RED: '#EA3232', }, + NumericEnum: { + TEST: 1, + }, Query: { color() { return '#EA3232'; }, + numericEnum() { + return 1; + } }, }; @@ -933,6 +973,7 @@ describe('generating schema from shorthand', () => { const resultPromise = graphql(jsSchema, testQuery); return resultPromise.then(result => { assert.equal(result.data['color'], 'RED'); + assert.equal(result.data['numericEnum'], 'TEST'); assert.equal(result.errors, undefined); }); }); diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts new file mode 100644 index 00000000000..3505a8072a7 --- /dev/null +++ b/src/test/testTransforms.ts @@ -0,0 +1,129 @@ +/* tslint:disable:no-unused-expression */ + +import { expect } from 'chai'; +import { + visit, + GraphQLSchema, + NamedTypeNode, + Kind, + GraphQLNamedType, + graphql, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from '../transforms/transforms'; +import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; +import { propertySchema } from './testingSchemas'; +import makeSimpleTransformSchema from '../transforms/makeSimpleTransformSchema'; + +function RenameTypes(renameMap: { [originalName: string]: string }): Transform { + const reverseMap = {}; + Object.keys(renameMap).map(from => { + reverseMap[renameMap[from]] = from; + }); + return { + transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return visitSchema(originalSchema, { + [VisitSchemaKind.TYPE]( + type: GraphQLNamedType, + ): GraphQLNamedType | undefined { + if (type.name in renameMap) { + const newType = Object.assign(Object.create(type), type); + newType.name = renameMap[type.name]; + return newType; + } + }, + }); + }, + + transformRequest(originalRequest: Request): Request { + const newDocument = visit(originalRequest.document, { + [Kind.NAMED_TYPE](node: NamedTypeNode): NamedTypeNode | undefined { + const name = node.name.value; + if (name in reverseMap) { + return { + ...node, + name: { + kind: Kind.NAME, + value: reverseMap[name], + }, + }; + } + }, + }); + return { + document: newDocument, + variables: originalRequest.variables, + }; + }, + }; +} + +// function NamespaceSchema(namespace: string): Transform { +// return { +// transformSchema();, +// }; +// } + +// function importFromSchema(importString: string) {} +// +// + +describe('transforms', () => { + describe('rename type', () => { + let schema: GraphQLSchema; + before(() => { + const transforms = [ + RenameTypes({ + Property: 'House', + Location: 'Spots', + TestInterface: 'TestingInterface', + DateTime: 'Datum', + InputWithDefault: 'DefaultingInput', + TestInterfaceKind: 'TestingInterfaceKinds', + }), + ]; + schema = makeSimpleTransformSchema(propertySchema, transforms); + }); + it('should work', async () => { + const result = await graphql( + schema, + ` + query($input: DefaultingInput!) { + interfaceTest(kind: ONE) { + ... on TestingInterface { + testString + } + } + propertyById(id: "p1") { + ... on House { + id + } + } + dateTimeTest + defaultInputTest(input: $input) + } + `, + {}, + {}, + { + input: { + test: 'bar', + }, + }, + ); + + expect(result).to.deep.equal({ + data: { + dateTimeTest: '1987-09-25T12:00:00', + defaultInputTest: 'bar', + interfaceTest: { + testString: 'test', + }, + propertyById: { + id: 'p1', + }, + }, + }); + }); + }); +}); diff --git a/src/test/testingSchemas.ts b/src/test/testingSchemas.ts index 54d91663b77..70cf93c1692 100644 --- a/src/test/testingSchemas.ts +++ b/src/test/testingSchemas.ts @@ -2,9 +2,11 @@ import { GraphQLSchema, graphql, print, + subscribe, Kind, GraphQLScalarType, ValueNode, + ExecutionResult, } from 'graphql'; import { ApolloLink, Observable } from 'apollo-link'; import { makeExecutableSchema } from '../schemaGenerator'; @@ -23,6 +25,13 @@ export type Property = { }; }; +export type Product = { + id: string; + price?: number; + url?: string, + type: string, +}; + export type Booking = { id: string; propertyId: string; @@ -47,10 +56,23 @@ export type Vehicle = { export const sampleData: { Property: { [key: string]: Property }; + Product: { [key: string]: Product }; Booking: { [key: string]: Booking }; Customer: { [key: string]: Customer }; Vehicle: { [key: string]: Vehicle }; } = { + Product: { + pd1: { + id: 'pd1', + type: 'simple', + price: 100, + }, + pd2: { + id: 'pd2', + type: 'download', + url: 'https://graphql.org', + }, + }, Property: { p1: { id: 'p1', @@ -347,6 +369,53 @@ const propertyResolvers: IResolvers = { }, }; +const productTypeDefs = ` + interface Product { + id: ID! + } + + interface Sellable { + price: Int! + } + + interface Downloadable { + url: String! + } + + type SimpleProduct implements Product, Sellable { + id: ID! + price: Int! + } + + type DownloadableProduct implements Product, Downloadable { + id: ID! + url: String! + } + + type Query { + products: [Product] + } +`; + +const productResolvers: IResolvers = { + Query: { + products(root) { + const list = values(sampleData.Product); + return list; + }, + }, + + Product: { + __resolveType(obj) { + if (obj.type === 'simple') { + return 'SimpleProduct'; + } else { + return 'DownloadableProduct'; + } + }, + }, +}; + const customerAddressTypeDef = ` type Customer implements Person { id: ID! @@ -549,6 +618,11 @@ export const propertySchema: GraphQLSchema = makeExecutableSchema({ resolvers: propertyResolvers, }); +export const productSchema: GraphQLSchema = makeExecutableSchema({ + typeDefs: productTypeDefs, + resolvers: productResolvers, +}); + export const bookingSchema: GraphQLSchema = makeExecutableSchema({ typeDefs: bookingAddressTypeDefs, resolvers: bookingResolvers, @@ -559,25 +633,69 @@ export const subscriptionSchema: GraphQLSchema = makeExecutableSchema({ resolvers: subscriptionResolvers, }); +const hasSubscriptionOperation = ({ query }: { query: any }): boolean => { + for (let definition of query.definitions) { + if (definition.kind === 'OperationDefinition') { + const operation = definition.operation; + if (operation === 'subscription') { + return true; + } + } + } + return false; +}; + // Pretend this schema is remote -async function makeSchemaRemoteFromLink(schema: GraphQLSchema) { +export async function makeSchemaRemoteFromLink(schema: GraphQLSchema) { const link = new ApolloLink(operation => { return new Observable(observer => { - const { query, operationName, variables } = operation; - const { graphqlContext } = operation.getContext(); - graphql( - schema, - print(query), - null, - graphqlContext, - variables, - operationName, - ) - .then(result => { - observer.next(result); - observer.complete(); - }) - .catch(observer.error.bind(observer)); + (async () => { + const { query, operationName, variables } = operation; + const { graphqlContext } = operation.getContext(); + try { + if (!hasSubscriptionOperation(operation)) { + const result = await graphql( + schema, + print(query), + null, + graphqlContext, + variables, + operationName, + ); + observer.next(result); + observer.complete(); + } else { + const result = await subscribe( + schema, + query, + null, + graphqlContext, + variables, + operationName, + ); + if ( + typeof (>result).next === + 'function' + ) { + while (true) { + const next = await (>result).next(); + observer.next(next.value); + if (next.done) { + observer.complete(); + break; + } + } + } else { + observer.next(result as ExecutionResult); + observer.complete(); + } + } + } catch (error) { + observer.error.bind(observer); + } + })(); }); }); @@ -602,6 +720,7 @@ async function makeExecutableSchemaFromFetcher(schema: GraphQLSchema) { } export const remotePropertySchema = makeSchemaRemoteFromLink(propertySchema); +export const remoteProductSchema = makeSchemaRemoteFromLink(productSchema); export const remoteBookingSchema = makeExecutableSchemaFromFetcher( bookingSchema, ); diff --git a/src/test/tests.ts b/src/test/tests.ts index f12e88f0964..b767315c5db 100755 --- a/src/test/tests.ts +++ b/src/test/tests.ts @@ -4,4 +4,6 @@ import './testSchemaGenerator'; import './testLogger'; import './testMocking'; import './testResolution'; +import './testMakeRemoteExecutableSchema'; import './testMergeSchemas'; +import './testTransforms'; diff --git a/src/transforms/AddArgumentsAsVariables.ts b/src/transforms/AddArgumentsAsVariables.ts new file mode 100644 index 00000000000..232b416d3c3 --- /dev/null +++ b/src/transforms/AddArgumentsAsVariables.ts @@ -0,0 +1,196 @@ +import { + ArgumentNode, + DocumentNode, + FragmentDefinitionNode, + GraphQLArgument, + GraphQLInputType, + GraphQLList, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + Kind, + OperationDefinitionNode, + SelectionNode, + TypeNode, + VariableDefinitionNode, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; + +export default function AddArgumentsAsVariablesTransform( + schema: GraphQLSchema, + args: { [key: string]: any }, +): Transform { + return { + transformRequest(originalRequest: Request): Request { + const { document, newVariables } = addVariablesToRootField( + schema, + originalRequest.document, + args, + ); + const variables = { + ...originalRequest.variables, + ...newVariables, + }; + return { + document, + variables, + }; + }, + }; +} + +function addVariablesToRootField( + targetSchema: GraphQLSchema, + document: DocumentNode, + args: { [key: string]: any }, +): { + document: DocumentNode; + newVariables: { [key: string]: any }; +} { + const operations: Array< + OperationDefinitionNode + > = document.definitions.filter( + def => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; + const fragments: Array = document.definitions.filter( + def => def.kind === Kind.FRAGMENT_DEFINITION, + ) as Array; + + const variableNames = {}; + + const newOperations = operations.map((operation: OperationDefinitionNode) => { + let existingVariables = operation.variableDefinitions.map( + (variableDefinition: VariableDefinitionNode) => + variableDefinition.variable.name.value, + ); + + let variableCounter = 0; + const variables = {}; + + const generateVariableName = (argName: string) => { + let varName; + do { + varName = `_v${variableCounter}_${argName}`; + variableCounter++; + } while (existingVariables.indexOf(varName) !== -1); + return varName; + }; + + let type: GraphQLObjectType; + if (operation.operation === 'subscription') { + type = targetSchema.getSubscriptionType(); + } else if (operation.operation === 'mutation') { + type = targetSchema.getMutationType(); + } else { + type = targetSchema.getQueryType(); + } + + const newSelectionSet: Array = []; + + operation.selectionSet.selections.forEach((selection: SelectionNode) => { + if (selection.kind === Kind.FIELD) { + let newArgs: { [name: string]: ArgumentNode } = {}; + selection.arguments.forEach((argument: ArgumentNode) => { + newArgs[argument.name.value] = argument; + }); + const name: string = selection.name.value; + const field: GraphQLField = type.getFields()[name]; + field.args.forEach((argument: GraphQLArgument) => { + if (argument.name in args) { + const variableName = generateVariableName(argument.name); + variableNames[argument.name] = variableName; + newArgs[argument.name] = { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: argument.name, + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: variableName, + }, + }, + }; + existingVariables.push(variableName); + variables[variableName] = { + kind: Kind.VARIABLE_DEFINITION, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: variableName, + }, + }, + type: typeToAst(argument.type), + }; + } + }); + + newSelectionSet.push({ + ...selection, + arguments: Object.keys(newArgs).map(argName => newArgs[argName]), + }); + } else { + newSelectionSet.push(selection); + } + }); + + return { + ...operation, + variableDefinitions: operation.variableDefinitions.concat( + Object.keys(variables).map(varName => variables[varName]), + ), + selectionSet: { + kind: Kind.SELECTION_SET, + selections: newSelectionSet, + }, + }; + }); + + const newVariables = {}; + Object.keys(variableNames).forEach(name => { + newVariables[variableNames[name]] = args[name]; + }); + + return { + document: { + ...document, + definitions: [...newOperations, ...fragments], + }, + newVariables, + }; +} + +function typeToAst(type: GraphQLInputType): TypeNode { + if (type instanceof GraphQLNonNull) { + const innerType = typeToAst(type.ofType); + if ( + innerType.kind === Kind.LIST_TYPE || + innerType.kind === Kind.NAMED_TYPE + ) { + return { + kind: Kind.NON_NULL_TYPE, + type: innerType, + }; + } else { + throw new Error('Incorrent inner non-null type'); + } + } else if (type instanceof GraphQLList) { + return { + kind: Kind.LIST_TYPE, + type: typeToAst(type.ofType), + }; + } else { + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: type.toString(), + }, + }; + } +} diff --git a/src/transforms/AddTypenameToAbstract.ts b/src/transforms/AddTypenameToAbstract.ts new file mode 100644 index 00000000000..5cf46c36e74 --- /dev/null +++ b/src/transforms/AddTypenameToAbstract.ts @@ -0,0 +1,75 @@ +import { + DocumentNode, + FieldNode, + GraphQLInterfaceType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + Kind, + SelectionSetNode, + TypeInfo, + visit, + visitWithTypeInfo, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; + +export default function AddTypenameToAbstract( + targetSchema: GraphQLSchema, +): Transform { + return { + transformRequest(originalRequest: Request): Request { + const document = addTypenameToAbstract( + targetSchema, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + }, + }; +} + +function addTypenameToAbstract( + targetSchema: GraphQLSchema, + document: DocumentNode, +): DocumentNode { + const typeInfo = new TypeInfo(targetSchema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: GraphQLType = typeInfo.getParentType(); + let selections = node.selections; + if ( + parentType && + (parentType instanceof GraphQLInterfaceType || + parentType instanceof GraphQLUnionType) && + !selections.find( + _ => + (_ as FieldNode).kind === Kind.FIELD && + (_ as FieldNode).name.value === '__typename', + ) + ) { + selections = selections.concat({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + }, + }), + ); +} diff --git a/src/transforms/CheckResultAndHandleErrors.ts b/src/transforms/CheckResultAndHandleErrors.ts new file mode 100644 index 00000000000..c27367e817d --- /dev/null +++ b/src/transforms/CheckResultAndHandleErrors.ts @@ -0,0 +1,14 @@ +import { GraphQLResolveInfo } from 'graphql'; +import { checkResultAndHandleErrors } from '../stitching/errors'; +import { Transform } from './transforms'; + +export default function CheckResultAndHandleErrors( + info: GraphQLResolveInfo, + fieldName?: string, +): Transform { + return { + transformResult(result: any): any { + return checkResultAndHandleErrors(result, info, fieldName); + }, + }; +} diff --git a/src/transforms/FilterToSchema.ts b/src/transforms/FilterToSchema.ts new file mode 100644 index 00000000000..2eb48f5ed23 --- /dev/null +++ b/src/transforms/FilterToSchema.ts @@ -0,0 +1,304 @@ +import { + ArgumentNode, + DocumentNode, + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + GraphQLInterfaceType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + InlineFragmentNode, + Kind, + OperationDefinitionNode, + SelectionSetNode, + TypeNameMetaFieldDef, + VariableDefinitionNode, + VariableNode, + visit, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; + +export default function FilterToSchema(targetSchema: GraphQLSchema): Transform { + return { + transformRequest(originalRequest: Request): Request { + const document = filterDocumentToSchema( + targetSchema, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + }, + }; +} + +function filterDocumentToSchema( + targetSchema: GraphQLSchema, + document: DocumentNode, +): DocumentNode { + const operations: Array< + OperationDefinitionNode + > = document.definitions.filter( + def => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; + const fragments: Array = document.definitions.filter( + def => def.kind === Kind.FRAGMENT_DEFINITION, + ) as Array; + + let usedVariables: Array = []; + let usedFragments: Array = []; + const newOperations: Array = []; + let newFragments: Array = []; + + const validFragments: Array< + FragmentDefinitionNode + > = fragments.filter((fragment: FragmentDefinitionNode) => { + const typeName = fragment.typeCondition.name.value; + const type = targetSchema.getType(typeName); + return Boolean(type); + }); + + const validFragmentsWithType: { [name: string]: GraphQLType } = {}; + validFragments.forEach((fragment: FragmentDefinitionNode) => { + const typeName = fragment.typeCondition.name.value; + const type = targetSchema.getType(typeName); + validFragmentsWithType[fragment.name.value] = type; + }); + + validFragments.forEach((fragment: FragmentDefinitionNode) => { + const name = fragment.name.value; + const typeName = fragment.typeCondition.name.value; + const type = targetSchema.getType(typeName); + const { + selectionSet, + usedFragments: fragmentUsedFragments, + usedVariables: fragmentUsedVariables, + } = filterSelectionSet( + targetSchema, + type, + validFragmentsWithType, + fragment.selectionSet, + ); + usedFragments = union(usedFragments, fragmentUsedFragments); + usedVariables = union(usedVariables, fragmentUsedVariables); + + newFragments.push({ + kind: Kind.FRAGMENT_DEFINITION, + name: { + kind: Kind.NAME, + value: name, + }, + typeCondition: fragment.typeCondition, + selectionSet, + }); + }); + + operations.forEach((operation: OperationDefinitionNode) => { + let type; + if (operation.operation === 'subscription') { + type = targetSchema.getSubscriptionType(); + } else if (operation.operation === 'mutation') { + type = targetSchema.getMutationType(); + } else { + type = targetSchema.getQueryType(); + } + const { + selectionSet, + usedFragments: operationUsedFragments, + usedVariables: operationUsedVariables, + } = filterSelectionSet( + targetSchema, + type, + validFragmentsWithType, + operation.selectionSet, + ); + + usedFragments = union(usedFragments, operationUsedFragments); + const fullUsedVariables = union(usedVariables, operationUsedVariables); + + const variableDefinitions = operation.variableDefinitions.filter( + (variable: VariableDefinitionNode) => + fullUsedVariables.indexOf(variable.variable.name.value) !== -1, + ); + + newOperations.push({ + kind: Kind.OPERATION_DEFINITION, + operation: operation.operation, + name: operation.name, + directives: operation.directives, + variableDefinitions, + selectionSet, + }); + }); + + newFragments = newFragments.filter( + (fragment: FragmentDefinitionNode) => + usedFragments.indexOf(fragment.name.value) !== -1, + ); + + return { + kind: Kind.DOCUMENT, + definitions: [...newOperations, ...newFragments], + }; +} + +function filterSelectionSet( + schema: GraphQLSchema, + type: GraphQLType, + validFragments: { [name: string]: GraphQLType }, + selectionSet: SelectionSetNode, +) { + const usedFragments: Array = []; + const usedVariables: Array = []; + const typeStack: Array = [type]; + + const filteredSelectionSet = visit(selectionSet, { + [Kind.FIELD]: { + enter(node: FieldNode): null | undefined | FieldNode { + let parentType: GraphQLNamedType = resolveType( + typeStack[typeStack.length - 1], + ); + if ( + parentType instanceof GraphQLObjectType || + parentType instanceof GraphQLInterfaceType + ) { + const fields = parentType.getFields(); + const field = + node.name.value === '__typename' + ? TypeNameMetaFieldDef + : fields[node.name.value]; + if (!field) { + return null; + } else { + typeStack.push(field.type); + } + + const argNames = (field.args || []).map(arg => arg.name); + if (node.arguments) { + let args = node.arguments.filter((arg: ArgumentNode) => { + return argNames.indexOf(arg.name.value) !== -1; + }); + if (args.length !== node.arguments.length) { + return { + ...node, + arguments: args, + }; + } + } + } else if ( + parentType instanceof GraphQLUnionType && + node.name.value === '__typename' + ) { + typeStack.push(TypeNameMetaFieldDef.type); + } + }, + leave() { + typeStack.pop(); + }, + }, + [Kind.FRAGMENT_SPREAD](node: FragmentSpreadNode): null | undefined { + if (node.name.value in validFragments) { + const parentType: GraphQLNamedType = resolveType( + typeStack[typeStack.length - 1], + ); + const innerType = validFragments[node.name.value]; + if (!implementsAbstractType(parentType, innerType)) { + return null; + } else { + usedFragments.push(node.name.value); + return; + } + } else { + return null; + } + }, + [Kind.INLINE_FRAGMENT]: { + enter(node: InlineFragmentNode): null | undefined { + if (node.typeCondition) { + const innerType = schema.getType(node.typeCondition.name.value); + const parentType: GraphQLNamedType = resolveType( + typeStack[typeStack.length - 1], + ); + if (implementsAbstractType(parentType, innerType)) { + typeStack.push(innerType); + } else { + return null; + } + } + }, + leave(node: InlineFragmentNode) { + typeStack.pop(); + }, + }, + [Kind.VARIABLE](node: VariableNode) { + usedVariables.push(node.name.value); + }, + }); + + return { + selectionSet: filteredSelectionSet, + usedFragments, + usedVariables, + }; +} + +function resolveType(type: GraphQLType): GraphQLNamedType { + let lastType = type; + while ( + lastType instanceof GraphQLNonNull || + lastType instanceof GraphQLList + ) { + lastType = lastType.ofType; + } + return lastType; +} + +function implementsAbstractType( + parent: GraphQLType, + child: GraphQLType, + bail: boolean = false, +): boolean { + if (parent === child) { + return true; + } else if ( + parent instanceof GraphQLInterfaceType && + child instanceof GraphQLObjectType + ) { + return child.getInterfaces().indexOf(parent) !== -1; + } else if ( + parent instanceof GraphQLInterfaceType && + child instanceof GraphQLInterfaceType + ) { + return true; + } else if ( + parent instanceof GraphQLUnionType && + child instanceof GraphQLObjectType + ) { + return parent.getTypes().indexOf(child) !== -1; + } else if (parent instanceof GraphQLObjectType && !bail) { + return implementsAbstractType(child, parent, true); + } + + return false; +} + +function union(...arrays: Array>): Array { + const cache: { [key: string]: Boolean } = {}; + const result: Array = []; + arrays.forEach(array => { + array.forEach(item => { + if (!cache[item]) { + cache[item] = true; + result.push(item); + } + }); + }); + return result; +} diff --git a/src/transforms/ReplaceFieldWithFragment.ts b/src/transforms/ReplaceFieldWithFragment.ts new file mode 100644 index 00000000000..d6229a20a05 --- /dev/null +++ b/src/transforms/ReplaceFieldWithFragment.ts @@ -0,0 +1,78 @@ +import { + DocumentNode, + GraphQLSchema, + GraphQLType, + InlineFragmentNode, + Kind, + SelectionSetNode, + TypeInfo, + visit, + visitWithTypeInfo, +} from 'graphql'; +import { Request } from '../Interfaces'; +import { Transform } from './transforms'; + +export type FieldToFragmentMapping = { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +}; + +export default function ReplaceFieldWithFragment( + targetSchema: GraphQLSchema, + mapping: FieldToFragmentMapping, +): Transform { + return { + transformRequest(originalRequest: Request): Request { + const document = replaceFieldsWithFragments( + targetSchema, + originalRequest.document, + mapping, + ); + return { + ...originalRequest, + document, + }; + }, + }; +} + +function replaceFieldsWithFragments( + targetSchema: GraphQLSchema, + document: DocumentNode, + mapping: FieldToFragmentMapping, +): DocumentNode { + const typeInfo = new TypeInfo(targetSchema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: GraphQLType = typeInfo.getParentType(); + if (parentType) { + const parentTypeName = parentType.name; + + let selections = node.selections; + + if (mapping[parentTypeName]) { + node.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) { + const name = selection.name.value; + const fragment = mapping[parentTypeName][name]; + if (fragment) { + selections = selections.concat(fragment); + } + } + }); + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + } + }, + }), + ); +} diff --git a/src/transforms/index.ts b/src/transforms/index.ts new file mode 100644 index 00000000000..ac8aeb69089 --- /dev/null +++ b/src/transforms/index.ts @@ -0,0 +1,16 @@ +import AddArgumentsAsVariables from './AddArgumentsAsVariables'; +import CheckResultAndHandleErrors from './CheckResultAndHandleErrors'; +import ReplaceFieldWithFragment from './ReplaceFieldWithFragment'; +import AddTypenameToAbstract from './AddTypenameToAbstract'; +import FilterToSchema from './FilterToSchema'; +import makeSimpleTransformSchema from './makeSimpleTransformSchema'; +export * from './transforms'; +export * from './visitSchema'; +export { makeSimpleTransformSchema }; +export const Transforms: { [name: string]: any } = { + AddArgumentsAsVariables, + CheckResultAndHandleErrors, + ReplaceFieldWithFragment, + AddTypenameToAbstract, + FilterToSchema, +}; diff --git a/src/transforms/makeSimpleTransformSchema.ts b/src/transforms/makeSimpleTransformSchema.ts new file mode 100644 index 00000000000..bc564e778ba --- /dev/null +++ b/src/transforms/makeSimpleTransformSchema.ts @@ -0,0 +1,23 @@ +import { GraphQLSchema } from 'graphql'; +import { addResolveFunctionsToSchema } from '../schemaGenerator'; + +import { Transform, applySchemaTransforms } from '../transforms/transforms'; +import { + generateProxyingResolvers, + generateSimpleMapping, +} from '../stitching/resolvers'; + +export default function makeSimpleTransformSchema( + targetSchema: GraphQLSchema, + transforms: Array, +) { + const schema = applySchemaTransforms(targetSchema, transforms); + const mapping = generateSimpleMapping(targetSchema); + const resolvers = generateProxyingResolvers( + targetSchema, + transforms, + mapping, + ); + addResolveFunctionsToSchema(schema, resolvers); + return schema; +} diff --git a/src/transforms/transforms.ts b/src/transforms/transforms.ts new file mode 100644 index 00000000000..459f40071a4 --- /dev/null +++ b/src/transforms/transforms.ts @@ -0,0 +1,44 @@ +import { GraphQLSchema } from 'graphql'; +import { Request, Result } from '../Interfaces'; + +export type Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (originalRequest: Request) => Request; + transformResult?: (result: Result) => Result; +}; + +export function applySchemaTransforms( + originalSchema: GraphQLSchema, + transforms: Array, +): GraphQLSchema { + return transforms.reduce( + (schema: GraphQLSchema, transform: Transform) => + transform.transformSchema ? transform.transformSchema(schema) : schema, + originalSchema, + ); +} + +export function applyRequestTransforms( + originalRequest: Request, + transforms: Array, +): Request { + return transforms.reduce( + (request: Request, transform: Transform) => + transform.transformRequest + ? transform.transformRequest(request) + : request, + + originalRequest, + ); +} + +export function applyResultTransforms( + originalResult: any, + transforms: Array, +): any { + return transforms.reduce( + (result: any, transform: Transform) => + transform.transformResult ? transform.transformResult(result) : result, + originalResult, + ); +} diff --git a/src/transforms/visitSchema.ts b/src/transforms/visitSchema.ts new file mode 100644 index 00000000000..1c6e7fec1b8 --- /dev/null +++ b/src/transforms/visitSchema.ts @@ -0,0 +1,142 @@ +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + GraphQLNamedType, + isNamedType, + getNamedType, +} from 'graphql'; +import { recreateType, createResolveType } from '../stitching/schemaRecreation'; + +export enum VisitSchemaKind { + TYPE = 'VisitSchemaKind.TYPE', + SCALAR_TYPE = 'VisitSchemaKind.SCALAR_TYPE', + ENUM_TYPE = 'VisitSchemaKind.ENUM_TYPE', + COMPOSITE_TYPE = 'VisitSchemaKind.COMPOSITE_TYPE', + OBJECT_TYPE = 'VisitSchemaKind.OBJECT_TYPE', + INPUT_OBJECT_TYPE = 'VisitSchemaKind.INPUT_OBJECT_TYPE', + ABSTRACT_TYPE = 'VisitSchemaKind.ABSTRACT_TYPE', + UNION_TYPE = 'VisitSchemaKind.UNION_TYPE', + INTERFACE_TYPE = 'VisitSchemaKind.INTERFACE_TYPE', + ROOT_OBJECT = 'VisitSchemaKind.ROOT_OBJECT', + QUERY = 'VisitSchemaKind.QUERY', + MUTATION = 'VisitSchemaKind.MUTATION', + SUBSCRIPTION = 'VisitSchemaKind.SUBSCRIPTION', +} +// I couldn't make keys to be forced to be enum values +export type SchemaVisitor = { [key: string]: TypeVisitor }; +export type TypeVisitor = ( + type: GraphQLType, + schema: GraphQLSchema, +) => GraphQLNamedType; + +export function visitSchema(schema: GraphQLSchema, visitor: SchemaVisitor) { + const types = {}; + const resolveType = createResolveType(name => { + if (typeof types[name] === 'undefined') { + throw new Error(`Can't find type ${name}.`); + } + return types[name]; + }); + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType(); + const subscriptionType = schema.getSubscriptionType(); + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).map((typeName: string) => { + const type = typeMap[typeName]; + if (isNamedType(type) && getNamedType(type).name.slice(0, 2) !== '__') { + const specifiers = getTypeSpecifiers(type, schema); + const typeVisitor = getVisitor(visitor, specifiers); + if (typeVisitor) { + const result: GraphQLNamedType | null | undefined = typeVisitor( + type, + schema, + ); + if (typeof result === 'undefined') { + types[typeName] = recreateType(type, resolveType); + } else if (result === null) { + types[typeName] = null; + } else { + types[typeName] = recreateType(result, resolveType); + } + } else { + types[typeName] = recreateType(type, resolveType); + } + } + }); + return new GraphQLSchema({ + query: queryType ? types[queryType.name] as GraphQLObjectType : null, + mutation: mutationType + ? types[mutationType.name] as GraphQLObjectType + : null, + subscription: subscriptionType + ? types[subscriptionType.name] as GraphQLObjectType + : null, + types: Object.keys(types).map(name => types[name]), + }); +} + +function getTypeSpecifiers( + type: GraphQLType, + schema: GraphQLSchema, +): Array { + const specifiers = [VisitSchemaKind.TYPE]; + if (type instanceof GraphQLObjectType) { + specifiers.unshift( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.OBJECT_TYPE, + ); + const query = schema.getQueryType(); + const mutation = schema.getMutationType(); + const subscription = schema.getSubscriptionType(); + if (type === query) { + specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.QUERY); + } else if (type === mutation) { + specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.MUTATION); + } else if (type === subscription) { + specifiers.push( + VisitSchemaKind.ROOT_OBJECT, + VisitSchemaKind.SUBSCRIPTION, + ); + } + } else if (type instanceof GraphQLInputObjectType) { + specifiers.push(VisitSchemaKind.INPUT_OBJECT_TYPE); + } else if (type instanceof GraphQLInterfaceType) { + specifiers.push( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.ABSTRACT_TYPE, + VisitSchemaKind.INTERFACE_TYPE, + ); + } else if (type instanceof GraphQLUnionType) { + specifiers.push( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.ABSTRACT_TYPE, + VisitSchemaKind.UNION_TYPE, + ); + } else if (type instanceof GraphQLEnumType) { + specifiers.push(VisitSchemaKind.ENUM_TYPE); + } else if (type instanceof GraphQLScalarType) { + specifiers.push(VisitSchemaKind.SCALAR_TYPE); + } + + return specifiers; +} + +function getVisitor( + visitor: SchemaVisitor, + specifiers: Array, +): TypeVisitor | null { + let typeVisitor = null; + const stack = [...specifiers]; + while (!typeVisitor && stack.length > 0) { + const next = stack.pop(); + typeVisitor = visitor[next]; + } + + return typeVisitor; +} From 6f3728ee81c735b46dc730b4d906053e7e701a80 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Thu, 15 Feb 2018 14:15:36 +0200 Subject: [PATCH 02/57] mre from master --- src/stitching/makeRemoteExecutableSchema.ts | 40 +++-------- src/stitching/observableToAsyncIterable.ts | 77 +++++++++++++++++++++ src/test/testMakeRemoteExecutableSchema.ts | 53 +++++++++++--- 3 files changed, 130 insertions(+), 40 deletions(-) create mode 100644 src/stitching/observableToAsyncIterable.ts diff --git a/src/stitching/makeRemoteExecutableSchema.ts b/src/stitching/makeRemoteExecutableSchema.ts index ea7038b8506..29ec6b8889c 100644 --- a/src/stitching/makeRemoteExecutableSchema.ts +++ b/src/stitching/makeRemoteExecutableSchema.ts @@ -29,7 +29,7 @@ import { makeExecutableSchema } from '../schemaGenerator'; import resolveParentFromTypename from './resolveFromParentTypename'; import defaultMergedResolver from './defaultMergedResolver'; import { checkResultAndHandleErrors } from './errors'; -import { PubSub, PubSubEngine } from 'graphql-subscriptions'; +import { observableToAsyncIterable } from './observableToAsyncIterable'; export type ResolverFn = ( rootValue?: any, @@ -51,12 +51,10 @@ export default function makeRemoteExecutableSchema({ schema, link, fetcher, - createPubSub, }: { schema: GraphQLSchema | string; link?: ApolloLink; fetcher?: Fetcher; - createPubSub?: () => PubSubEngine; }): GraphQLSchema { if (!fetcher && link) { fetcher = linkToFetcher(link); @@ -93,11 +91,10 @@ export default function makeRemoteExecutableSchema({ const subscriptionResolvers: IResolverObject = {}; const subscriptionType = schema.getSubscriptionType(); if (subscriptionType) { - const pubSub = createPubSub ? createPubSub() : new PubSub(); const subscriptions = subscriptionType.getFields(); Object.keys(subscriptions).forEach(key => { subscriptionResolvers[key] = { - subscribe: createSubscriptionResolver(key, link, pubSub), + subscribe: createSubscriptionResolver(key, link), }; }); } @@ -117,10 +114,7 @@ export default function makeRemoteExecutableSchema({ const typeMap = schema.getTypeMap(); const types = Object.keys(typeMap).map(name => typeMap[name]); for (const type of types) { - if ( - type instanceof GraphQLInterfaceType || - type instanceof GraphQLUnionType - ) { + if (type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType) { resolvers[type.name] = { __resolveType(parent, context, info) { return resolveParentFromTypename(parent, info.schema); @@ -161,9 +155,7 @@ export default function makeRemoteExecutableSchema({ function createResolver(fetcher: Fetcher): GraphQLFieldResolver { return async (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map( - fragment => info.fragments[fragment], - ); + const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); const document = { kind: Kind.DOCUMENT, definitions: [info.operation, ...fragments], @@ -177,15 +169,9 @@ function createResolver(fetcher: Fetcher): GraphQLFieldResolver { }; } -function createSubscriptionResolver( - name: string, - link: ApolloLink, - pubSub: PubSubEngine, -): ResolverFn { +function createSubscriptionResolver(name: string, link: ApolloLink): ResolverFn { return (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map( - fragment => info.fragments[fragment], - ); + const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); const document = { kind: Kind.DOCUMENT, definitions: [info.operation, ...fragments], @@ -196,20 +182,10 @@ function createSubscriptionResolver( variables: info.variableValues, context: { graphqlContext: context }, }; - const observable = execute(link, operation); - const observer = { - next(value: any) { - pubSub.publish(`remote-schema-${name}`, value.data); - }, - error(err: Error) { - pubSub.publish(`remote-schema-${name}`, { errors: [err] }); - }, - }; - - observable.subscribe(observer); + const observable = execute(link, operation); - return pubSub.asyncIterator(`remote-schema-${name}`); + return observableToAsyncIterable(observable); }; } diff --git a/src/stitching/observableToAsyncIterable.ts b/src/stitching/observableToAsyncIterable.ts new file mode 100644 index 00000000000..9de23102b7b --- /dev/null +++ b/src/stitching/observableToAsyncIterable.ts @@ -0,0 +1,77 @@ +import { Observable } from 'apollo-link'; +import { $$asyncIterator } from 'iterall'; +type Callback = (value?: any) => any; + +export function observableToAsyncIterable(observable: Observable): AsyncIterator { + const pullQueue: Callback[] = []; + const pushQueue: any[] = []; + + let listening = true; + + const pushValue = ({ data }: any) => { + if (pullQueue.length !== 0) { + pullQueue.shift()({ value: data, done: false }); + } else { + pushQueue.push({ value: data }); + } + }; + + const pushError = (error: any) => { + if (pullQueue.length !== 0) { + pullQueue.shift()({ value: { errors: [error] }, done: false }); + } else { + pushQueue.push({ value: { errors: [error] } }); + } + }; + + const pullValue = () => { + return new Promise(resolve => { + if (pushQueue.length !== 0) { + const element = pushQueue.shift(); + // either {value: {errors: [...]}} or {value: ...} + resolve({ + ...element, + done: false, + }); + } else { + pullQueue.push(resolve); + } + }); + }; + + const subscription = observable.subscribe({ + next(value: any) { + pushValue(value); + }, + error(err: Error) { + pushError(err); + }, + }); + + const emptyQueue = () => { + if (listening) { + listening = false; + subscription.unsubscribe(); + pullQueue.forEach(resolve => resolve({ value: undefined, done: true })); + pullQueue.length = 0; + pushQueue.length = 0; + } + }; + + return { + async next() { + return listening ? pullValue() : this.return(); + }, + return() { + emptyQueue(); + return Promise.resolve({ value: undefined, done: true }); + }, + throw(error) { + emptyQueue(); + return Promise.reject(error); + }, + [$$asyncIterator]() { + return this; + }, + }; +} diff --git a/src/test/testMakeRemoteExecutableSchema.ts b/src/test/testMakeRemoteExecutableSchema.ts index 7eb79dc6d05..3ad4cf3cc2c 100644 --- a/src/test/testMakeRemoteExecutableSchema.ts +++ b/src/test/testMakeRemoteExecutableSchema.ts @@ -33,16 +33,53 @@ describe('remote subscriptions', () => { let notificationCnt = 0; subscribe(schema, subscription).then(results => - forAwaitEach( - results as AsyncIterable, - (result: ExecutionResult) => { - expect(result).to.have.property('data'); - expect(result.data).to.deep.equal(mockNotification); - !notificationCnt++ ? done() : null; - }, - ), + forAwaitEach(results as AsyncIterable, (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockNotification); + !notificationCnt++ ? done() : null; + }), ); subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification); }); + + it('should work without triggering multiple times per notification', done => { + const mockNotification = { + notifications: { + text: 'Hello world', + }, + }; + + const subscription = parse(` + subscription Subscription { + notifications { + text + } + } + `); + + let notificationCnt = 0; + subscribe(schema, subscription).then(results => + forAwaitEach(results as AsyncIterable, (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockNotification); + notificationCnt++; + }), + ); + + subscribe(schema, subscription).then(results => + forAwaitEach(results as AsyncIterable, (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockNotification); + }), + ); + + subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification); + subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification); + + setTimeout(() => { + expect(notificationCnt).to.eq(2); + done(); + }, 0); + }); }); From 0c1b48aae3718d6ecd7240352c1a016c18dae8de Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Thu, 15 Feb 2018 15:22:01 +0200 Subject: [PATCH 03/57] Make all tests pass --- package.json | 3 +- src/stitching/delegateToSchema.ts | 27 +++++++++-- src/stitching/mergeSchemas.ts | 27 ++--------- src/test/testMergeSchemas.ts | 78 ++++++++++++++++++++++--------- src/test/testSchemaGenerator.ts | 5 -- 5 files changed, 84 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 953b5768355..d6b0f0d6a36 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,8 @@ }, "homepage": "https://github.com/apollostack/graphql-tools#readme", "dependencies": { - "apollo-link": "^1.1.0", - "apollo-utilities": "^1.0.1", "apollo-link": "^1.0.0", + "apollo-utilities": "^1.0.1", "deprecated-decorator": "^0.1.6", "iterall": "^1.1.3", "uuid": "^3.1.0" diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 90f7d2cafde..04bbda2d045 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -1,4 +1,5 @@ import { + ArgumentNode, DocumentNode, FieldNode, FragmentDefinitionNode, @@ -92,16 +93,34 @@ export default async function delegateToSchema( export function createDocument( targetField: string, targetOperation: Operation, - selections: Array, + originalSelections: Array, fragments: Array, variables: Array, ): DocumentNode { - const originalSelection = selections[0] as FieldNode; + let selections: Array = []; + let args: Array = []; + + originalSelections.forEach((field: FieldNode) => { + const fieldSelections = field.selectionSet + ? field.selectionSet.selections + : []; + selections = selections.concat(fieldSelections); + args = args.concat(field.arguments || []); + }); + + let selectionSet = null; + if (selections.length > 0) { + selectionSet = { + kind: Kind.SELECTION_SET, + selections: selections, + }; + } + const rootField: FieldNode = { kind: Kind.FIELD, alias: null, - arguments: originalSelection.arguments, - selectionSet: originalSelection.selectionSet, + arguments: args, + selectionSet, name: { kind: Kind.NAME, value: targetField, diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index c39d772925f..ee7fc9889ec 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -37,6 +37,7 @@ import { import delegateToSchema from './delegateToSchema'; import typeFromAST, { GetType } from './typeFromAST'; import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; +import mergeDeep from '../mergeDeep'; export default function mergeSchemas({ schemas, @@ -45,7 +46,7 @@ export default function mergeSchemas({ }: { schemas: Array<{ name: string; schema: string | GraphQLSchema }>; visitType?: VisitType; - resolvers?: IResolvers | ((mergeInfo: MergeInfo) => IResolvers); + resolvers?: Array | IResolvers; }): GraphQLSchema { const allSchemas: { [name: string]: GraphQLSchema } = {}; const typeCandidates: { [name: string]: Array } = {}; @@ -55,6 +56,8 @@ export default function mergeSchemas({ if (!resolvers) { resolvers = {}; + } else if (Array.isArray(resolvers)) { + resolvers = resolvers.reduce(mergeDeep, {}); } if (!visitType) { @@ -320,28 +323,6 @@ function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { }); } -function isObject(item: any): Boolean { - return item && typeof item === 'object' && !Array.isArray(item); -} - -function mergeDeep(target: any, source: any): any { - let output = Object.assign({}, target); - if (isObject(target) && isObject(source)) { - Object.keys(source).forEach(key => { - if (isObject(source[key])) { - if (!(key in target)) { - Object.assign(output, { [key]: source[key] }); - } else { - output[key] = mergeDeep(target[key], source[key]); - } - } else { - Object.assign(output, { [key]: source[key] }); - } - }); - } - return output; -} - function parseFragmentToInlineFragment( definitions: string, ): InlineFragmentNode { diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 42f9190a8a3..39e284ade7f 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -5,6 +5,7 @@ import { graphql, GraphQLSchema, GraphQLObjectType, + GraphQLScalarType, subscribe, parse, ExecutionResult, @@ -1025,7 +1026,7 @@ bookingById(id: "b1") { }); it('should merge resolvers when passed an array of resolver objects', async () => { - const Scalars = () => ({ + const Scalars = { TestScalar: new GraphQLScalarType({ name: 'TestScalar', description: undefined, @@ -1033,21 +1034,22 @@ bookingById(id: "b1") { parseValue: value => value, parseLiteral: () => null, }), - }); - const Enums = () => ({ + }; + const Enums = { NumericEnum: { TEST: 1, }, Color: { RED: '#EA3232', }, - }); + }; const PropertyResolvers: IResolvers = { Property: { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { return info.mergeInfo.delegate( + 'Property', 'query', 'bookingsByPropertyId', { @@ -1061,12 +1063,13 @@ bookingById(id: "b1") { }, }, }; - const LinkResolvers: (info: any) => IResolvers = info => ({ + const LinkResolvers: IResolvers = { Booking: { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', - resolve(parent, args, context) { + resolve(parent, args, context, info) { return info.mergeInfo.delegate( + 'Booking', 'query', 'propertyById', { @@ -1078,8 +1081,8 @@ bookingById(id: "b1") { }, }, }, - }); - const Query1 = () => ({ + }; + const Query1 = { Query: { color() { return '#EA3232'; @@ -1088,11 +1091,12 @@ bookingById(id: "b1") { return 1; }, }, - }); - const Query2: (info: any) => IResolvers = () => ({ + }; + const Query2: IResolvers = { Query: { delegateInterfaceTest(parent, args, context, info) { return info.mergeInfo.delegate( + 'Property', 'query', 'interfaceTest', { @@ -1104,6 +1108,7 @@ bookingById(id: "b1") { }, delegateArgumentTest(parent, args, context, info) { return info.mergeInfo.delegate( + 'Property', 'query', 'propertyById', { @@ -1124,6 +1129,7 @@ bookingById(id: "b1") { resolve(parent, args, context, info) { if (args.id.startsWith('p')) { return info.mergeInfo.delegate( + 'Property', 'query', 'propertyById', args, @@ -1132,6 +1138,7 @@ bookingById(id: "b1") { ); } else if (args.id.startsWith('b')) { return info.mergeInfo.delegate( + 'Booking', 'query', 'bookingById', args, @@ -1140,6 +1147,7 @@ bookingById(id: "b1") { ); } else if (args.id.startsWith('c')) { return info.mergeInfo.delegate( + 'Booking', 'query', 'customerById', args, @@ -1152,12 +1160,13 @@ bookingById(id: "b1") { }, }, }, - }); + }; - const AsyncQuery: (info: any) => IResolvers = info => ({ + const AsyncQuery: IResolvers = { Query: { - async nodes(parent, args, context) { + async nodes(parent, args, context, info) { const bookings = await info.mergeInfo.delegate( + 'Booking', 'query', 'bookings', {}, @@ -1165,6 +1174,7 @@ bookingById(id: "b1") { info, ); const properties = await info.mergeInfo.delegate( + 'Property', 'query', 'properties', {}, @@ -1174,17 +1184,41 @@ bookingById(id: "b1") { return [...bookings, ...properties]; }, }, - }); + }; const schema = mergeSchemas({ schemas: [ - propertySchema, - bookingSchema, - productSchema, - scalarTest, - enumTest, - linkSchema, - loneExtend, - localSubscriptionSchema, + { + name: 'Property', + schema: propertySchema, + }, + { + name: 'Booking', + schema: bookingSchema, + }, + { + name: 'Product', + schema: productSchema, + }, + { + name: 'ScalarTest', + schema: scalarTest, + }, + { + name: 'EnumTest', + schema: enumSchema, + }, + { + name: 'LinkSchema', + schema: linkSchema, + }, + { + name: 'LoneExtend', + schema: loneExtend, + }, + { + name: 'LocalSubscription', + schema: localSubscriptionSchema, + }, ], resolvers: [ Scalars, diff --git a/src/test/testSchemaGenerator.ts b/src/test/testSchemaGenerator.ts index 048fa9deab5..9f93163dece 100644 --- a/src/test/testSchemaGenerator.ts +++ b/src/test/testSchemaGenerator.ts @@ -983,13 +983,8 @@ describe('generating schema from shorthand', () => { RED: '#EA3232', }, NumericEnum: { -<<<<<<< HEAD - TEST: 1 - } -======= TEST: 1, }, ->>>>>>> origin/master }; const jsSchema = makeExecutableSchema({ From 59d2779bba550f3bfcb391cba15cad67fbec887f Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 20 Feb 2018 15:06:04 +0200 Subject: [PATCH 04/57] Namespacing schema --- src/test/testTransforms.ts | 116 +++++++++++++++++++++++++++------- src/transforms/visitObject.ts | 40 ++++++++++++ 2 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 src/transforms/visitObject.ts diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index 3505a8072a7..363a86b4e60 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -9,29 +9,33 @@ import { GraphQLNamedType, graphql, } from 'graphql'; -import { Request } from '../Interfaces'; +import { Request, Result } from '../Interfaces'; import { Transform } from '../transforms/transforms'; import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; +import visitObject from '../transforms/visitObject'; import { propertySchema } from './testingSchemas'; import makeSimpleTransformSchema from '../transforms/makeSimpleTransformSchema'; -function RenameTypes(renameMap: { [originalName: string]: string }): Transform { +function RenameTypes(renamer: (name: string) => string | undefined): Transform { const reverseMap = {}; - Object.keys(renameMap).map(from => { - reverseMap[renameMap[from]] = from; - }); return { transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { return visitSchema(originalSchema, { [VisitSchemaKind.TYPE]( type: GraphQLNamedType, ): GraphQLNamedType | undefined { - if (type.name in renameMap) { + const newName = renamer(type.name); + if (newName && newName !== type.name) { + reverseMap[newName] = type.name; const newType = Object.assign(Object.create(type), type); - newType.name = renameMap[type.name]; + newType.name = newName; return newType; } }, + + [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { + return undefined; + }, }); }, @@ -55,32 +59,49 @@ function RenameTypes(renameMap: { [originalName: string]: string }): Transform { variables: originalRequest.variables, }; }, + + transformResult(result: Result): Result { + if (result.data) { + const newData = visitObject(result.data, (key, value) => { + if (key === '__typename') { + return renamer(value); + } + }); + const newResult = { + ...result, + data: newData, + }; + return newResult; + } + return result; + }, }; } - -// function NamespaceSchema(namespace: string): Transform { -// return { -// transformSchema();, -// }; -// } - -// function importFromSchema(importString: string) {} // +// type ImportDefinition = { +// types: {}; +// fields: {}; +// }; // +// function importFromSchema(importString: string) {} describe('transforms', () => { describe('rename type', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - RenameTypes({ - Property: 'House', - Location: 'Spots', - TestInterface: 'TestingInterface', - DateTime: 'Datum', - InputWithDefault: 'DefaultingInput', - TestInterfaceKind: 'TestingInterfaceKinds', - }), + RenameTypes( + name => + ({ + Property: 'House', + Location: 'Spots', + TestInterface: 'TestingInterface', + DateTime: 'Datum', + InputWithDefault: 'DefaultingInput', + TestInterfaceKind: 'TestingInterfaceKinds', + TestImpl1: 'TestImplementation1', + }[name]), + ), ]; schema = makeSimpleTransformSchema(propertySchema, transforms); }); @@ -126,4 +147,53 @@ describe('transforms', () => { }); }); }); + + describe('namespace', () => { + let schema: GraphQLSchema; + before(() => { + const transforms = [RenameTypes(name => `Property_${name}`)]; + schema = makeSimpleTransformSchema(propertySchema, transforms); + }); + it('should work', async () => { + const result = await graphql( + schema, + ` + query($input: Property_InputWithDefault!) { + interfaceTest(kind: ONE) { + ... on Property_TestInterface { + testString + } + } + propertyById(id: "p1") { + ... on Property_Property { + id + } + } + dateTimeTest + defaultInputTest(input: $input) + } + `, + {}, + {}, + { + input: { + test: 'bar', + }, + }, + ); + + expect(result).to.deep.equal({ + data: { + dateTimeTest: '1987-09-25T12:00:00', + defaultInputTest: 'bar', + interfaceTest: { + testString: 'test', + }, + propertyById: { + id: 'p1', + }, + }, + }); + }); + }); }); diff --git a/src/transforms/visitObject.ts b/src/transforms/visitObject.ts new file mode 100644 index 00000000000..f0efbce6b70 --- /dev/null +++ b/src/transforms/visitObject.ts @@ -0,0 +1,40 @@ +// depth-first search of any object. +export type ObjectVisitor = ( + key: string, + value: any, + parents: Array, +) => any | undefined | null; + +export default function visitObject(object: any, visitor: ObjectVisitor) { + return visitObjectImpl(object, visitor, []); +} + +function visitObjectImpl( + object: any, + visitor: ObjectVisitor, + parents: Array, +) { + if (isObject(object)) { + const result = {}; + Object.keys(object).forEach(key => { + const value = object[key]; + const processedValue = visitor(key, value, parents); + let nextValue; + if (typeof processedValue === 'undefined') { + nextValue = value; + } else if (processedValue === null) { + return; + } else { + nextValue = processedValue; + } + result[key] = visitObjectImpl(nextValue, visitor, [key, ...parents]); + }); + return result; + } else { + return object; + } +} + +function isObject(item: any): Boolean { + return item && typeof item === 'object' && !Array.isArray(item); +} From a0e84168a5075b334157ae0f2ecec05185070d99 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 21 Feb 2018 12:49:15 +0200 Subject: [PATCH 05/57] Backwards compatible implementation --- src/stitching/mergeSchemas.ts | 55 +++++++++++++++++++++++++++++++++-- src/test/testMergeSchemas.ts | 6 ++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index ee7fc9889ec..56cb0509322 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -40,6 +40,36 @@ import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; import mergeDeep from '../mergeDeep'; export default function mergeSchemas({ + schemas, + onTypeConflict, + resolvers, +}: { + schemas: Array<{ name: string; schema: string | GraphQLSchema }>; + onTypeConflict?: ( + left: GraphQLNamedType, + right: GraphQLNamedType, + ) => GraphQLNamedType; + resolvers?: Array | IResolvers; +}): GraphQLSchema { + if (schemas.some(schema => !(schema.name && schema.schema))) { + throw new Error( + `Invalid argument \`schemas\`. Expected array of objects of format \`{ name: string, schema: string | schema }\`. + +Argument expected value have been changed in version 3.0, have you updated your code?`, + ); + } + + let visitType: VisitType = defaultVisitType; + if (onTypeConflict) { + console.warn( + '`onTypeConflict` is deprecated. Use schema transforms to customize merging logic.', + ); + visitType = createVisitTypeFromOnTypeConflict(onTypeConflict); + } + return mergeSchemasImplementation({ schemas, visitType, resolvers }); +} + +export function mergeSchemasImplementation({ schemas, visitType, resolvers, @@ -350,10 +380,31 @@ function addTypeCandidate( typeCandidates[name].push(typeCandidate); } -const defaultVisitType: VisitType = ( +function createVisitTypeFromOnTypeConflict( + onTypeConflict: ( + left: GraphQLNamedType, + right: GraphQLNamedType, + ) => GraphQLNamedType, +): VisitType { + return (name, candidates) => + defaultVisitType(name, candidates, cands => + cands.reduce( + (prev, next) => onTypeConflict(prev, next.type), + cands[0].type, + ), + ); +} + +const defaultVisitType = ( name: string, candidates: Array, + candidateSelector?: ( + candidates: Array, + ) => GraphQLNamedType, ) => { + if (!candidateSelector) { + candidateSelector = cands => cands[cands.length - 1].type; + } const resolveType = createResolveType((_, type) => type); if (name === 'Query' || name === 'Mutation' || name === 'Subscription') { let fields = {}; @@ -396,6 +447,6 @@ const defaultVisitType: VisitType = ( resolvers, }; } else { - return candidates[candidates.length - 1].type; + return candidateSelector(candidates); } }; diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 39e284ade7f..c08a4274dea 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -11,7 +11,9 @@ import { ExecutionResult, } from 'graphql'; import { VisitType } from '../Interfaces'; -import mergeSchemas from '../stitching/mergeSchemas'; +import mergeSchemas, { + mergeSchemasImplementation, +} from '../stitching/mergeSchemas'; import { propertySchema as localPropertySchema, productSchema as localProductSchema, @@ -2453,7 +2455,7 @@ describe('mergeSchema options', () => { } }; }; - schema = mergeSchemas({ + schema = mergeSchemasImplementation({ schemas: [ { name: 'Booking', From 4b108cbbf331c5b88d0139523c31bf1137a7b728 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 21 Feb 2018 12:49:31 +0200 Subject: [PATCH 06/57] Alpha 6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d6b0f0d6a36..af06ec1fed2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.5", + "version": "3.0.0-alpha.6", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From a986cc98964755d268f0d95d1d9af0bbbedd965a Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 21 Feb 2018 12:53:10 +0200 Subject: [PATCH 07/57] More helpful warnings --- src/stitching/mergeSchemas.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 56cb0509322..7e4a3de143e 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -291,6 +291,11 @@ function createMergeInfo( context: { [key: string]: any }, info: GraphQLResolveInfo, ): any { + if (!info) { + throw new Error(`Argument \`info\` is missing. + +In version 3.0, \`delegate\` requires a schema name as a first argument, have you updated your code?`); + } const schema = schemas[schemaName]; const fragmentTransform = ReplaceFieldWithFragment( schema, From 4fcd04bc8cf4e36da27709f9c28da6263a4027e9 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 21 Feb 2018 12:56:52 +0200 Subject: [PATCH 08/57] Drop old versions --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 950bb2d2009..e33932e3409 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: node_js node_js: - - "4" - "6" - "8" @@ -20,6 +19,5 @@ script: sudo: false env: - - GRAPHQL_VERSION='^0.11' - GRAPHQL_VERSION='^0.12' - GRAPHQL_VERSION='^0.13' From a21165d2f55cb168d3a120b95ac7221e4ab4892c Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 21 Feb 2018 13:06:22 +0200 Subject: [PATCH 09/57] Update dependencies --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index af06ec1fed2..f482ce82469 100644 --- a/package.json +++ b/package.json @@ -48,14 +48,14 @@ }, "homepage": "https://github.com/apollostack/graphql-tools#readme", "dependencies": { - "apollo-link": "^1.0.0", + "apollo-link": "^1.1.0", "apollo-utilities": "^1.0.1", "deprecated-decorator": "^0.1.6", "iterall": "^1.1.3", "uuid": "^3.1.0" }, "peerDependencies": { - "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0" + "graphql": "^0.12.0 || ^0.13.0" }, "devDependencies": { "@types/chai": "4.0.10", From 1c0f3f8c4db6fa6201d1a3bf27d6771073430ca9 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 21 Feb 2018 13:23:08 +0200 Subject: [PATCH 10/57] Lock apollo-link to lower version to avoid their bug --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f482ce82469..37b522ade41 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "homepage": "https://github.com/apollostack/graphql-tools#readme", "dependencies": { - "apollo-link": "^1.1.0", + "apollo-link": "~1.1.0", "apollo-utilities": "^1.0.1", "deprecated-decorator": "^0.1.6", "iterall": "^1.1.3", From f914bee65fb1aed85743389f95c6b0bcd9ae2ec4 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 26 Feb 2018 15:25:32 +0200 Subject: [PATCH 11/57] Richer onTypeConflict --- src/stitching/mergeSchemas.ts | 58 ++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 7e4a3de143e..96a39e9fa50 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -39,16 +39,28 @@ import typeFromAST, { GetType } from './typeFromAST'; import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; import mergeDeep from '../mergeDeep'; +export type OnTypeConflict = ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + name: string; + schema?: GraphQLSchema; + }; + right: { + name: string; + schema?: GraphQLSchema; + }; + }, +) => GraphQLNamedType; + export default function mergeSchemas({ schemas, onTypeConflict, resolvers, }: { schemas: Array<{ name: string; schema: string | GraphQLSchema }>; - onTypeConflict?: ( - left: GraphQLNamedType, - right: GraphQLNamedType, - ) => GraphQLNamedType; + onTypeConflict?: OnTypeConflict; resolvers?: Array | IResolvers; }): GraphQLSchema { if (schemas.some(schema => !(schema.name && schema.schema))) { @@ -386,17 +398,32 @@ function addTypeCandidate( } function createVisitTypeFromOnTypeConflict( - onTypeConflict: ( - left: GraphQLNamedType, - right: GraphQLNamedType, - ) => GraphQLNamedType, + onTypeConflict: OnTypeConflict, ): VisitType { return (name, candidates) => defaultVisitType(name, candidates, cands => - cands.reduce( - (prev, next) => onTypeConflict(prev, next.type), - cands[0].type, - ), + cands.reduce((prev, next) => { + const type = onTypeConflict(prev.type, next.type, { + left: { + name: prev.schemaName, + schema: prev.schema, + }, + right: { + name: prev.schemaName, + schema: prev.schema, + }, + }); + if (prev.type === type) { + return prev; + } else if (next.type === type) { + return next; + } else { + return { + schemaName: 'unknown', + type, + }; + } + }), ); } @@ -405,10 +432,10 @@ const defaultVisitType = ( candidates: Array, candidateSelector?: ( candidates: Array, - ) => GraphQLNamedType, + ) => MergeTypeCandidate, ) => { if (!candidateSelector) { - candidateSelector = cands => cands[cands.length - 1].type; + candidateSelector = cands => cands[cands.length - 1]; } const resolveType = createResolveType((_, type) => type); if (name === 'Query' || name === 'Mutation' || name === 'Subscription') { @@ -452,6 +479,7 @@ const defaultVisitType = ( resolvers, }; } else { - return candidateSelector(candidates); + const candidate = candidateSelector(candidates); + return candidate.type; } }; From 65bd750ba996a4672ec87f6e4390b96e3dea8ba4 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 27 Feb 2018 12:09:19 +0200 Subject: [PATCH 12/57] Alpha 7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 37b522ade41..565de261f7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From 033efb57ea9f0ed8830c89e5f5e4085408a0fdb2 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Wed, 28 Feb 2018 15:13:51 +0200 Subject: [PATCH 13/57] Filtering types --- src/stitching/schemaRecreation.ts | 29 +++--- src/test/testTransforms.ts | 97 +++++++++++++++++++-- src/transforms/makeSimpleTransformSchema.ts | 4 +- src/transforms/transforms.ts | 15 ++++ 4 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/stitching/schemaRecreation.ts b/src/stitching/schemaRecreation.ts index f08f48a41c0..78b9c8766ca 100644 --- a/src/stitching/schemaRecreation.ts +++ b/src/stitching/schemaRecreation.ts @@ -209,8 +209,10 @@ function argsToFieldConfigArgumentMap( ): GraphQLFieldConfigArgumentMap { const result: GraphQLFieldConfigArgumentMap = {}; args.forEach(arg => { - const [name, def] = argumentToArgumentConfig(arg, resolveType); - result[name] = def; + const newArg = argumentToArgumentConfig(arg, resolveType); + if (newArg) { + result[newArg[0]] = newArg[1]; + } }); return result; } @@ -218,15 +220,20 @@ function argsToFieldConfigArgumentMap( function argumentToArgumentConfig( argument: GraphQLArgument, resolveType: ResolveType, -): [string, GraphQLArgumentConfig] { - return [ - argument.name, - { - type: resolveType(argument.type), - defaultValue: argument.defaultValue, - description: argument.description, - }, - ]; +): [string, GraphQLArgumentConfig] | null { + const type = resolveType(argument.type); + if (type === null) { + return null; + } else { + return [ + argument.name, + { + type: type, + defaultValue: argument.defaultValue, + description: argument.description, + }, + ]; + } } function inputFieldMapToFieldConfigMap( diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index 363a86b4e60..d4cb193f1c4 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -13,7 +13,7 @@ import { Request, Result } from '../Interfaces'; import { Transform } from '../transforms/transforms'; import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; import visitObject from '../transforms/visitObject'; -import { propertySchema } from './testingSchemas'; +import { propertySchema, bookingSchema } from './testingSchemas'; import makeSimpleTransformSchema from '../transforms/makeSimpleTransformSchema'; function RenameTypes(renamer: (name: string) => string | undefined): Transform { @@ -77,13 +77,22 @@ function RenameTypes(renamer: (name: string) => string | undefined): Transform { }, }; } -// -// type ImportDefinition = { -// types: {}; -// fields: {}; -// }; -// -// function importFromSchema(importString: string) {} + +function FilterTypes(filter: (type: GraphQLNamedType) => Boolean) { + return { + transformSchema(schema: GraphQLSchema): GraphQLSchema { + return visitSchema(schema, { + [VisitSchemaKind.TYPE](type: GraphQLNamedType): null | undefined { + if (filter(type)) { + return undefined; + } else { + return null; + } + }, + }); + }, + }; +} describe('transforms', () => { describe('rename type', () => { @@ -196,4 +205,76 @@ describe('transforms', () => { }); }); }); + + describe('filter type', () => { + let schema: GraphQLSchema; + before(() => { + const transforms = [ + FilterTypes(type => + ['ID', 'String', 'DateTime', 'Query', 'Booking'].includes(type.name), + ), + ]; + schema = makeSimpleTransformSchema(bookingSchema, transforms); + }); + + it('should work normally', async () => { + const result = await graphql( + schema, + ` + query { + bookingById(id: "b1") { + id + propertyId + startTime + endTime + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + bookingById: { + endTime: '2016-06-03', + id: 'b1', + propertyId: 'p1', + startTime: '2016-05-04', + }, + }, + }); + }); + + it('should error on removed types', async () => { + const result = await graphql( + schema, + ` + query { + bookingById(id: "b1") { + id + propertyId + startTime + endTime + customer { + id + } + } + } + `, + ); + expect(result).to.deep.equal({ + errors: [ + { + locations: [ + { + column: 15, + line: 8, + }, + ], + message: 'Cannot query field "customer" on type "Booking".', + path: undefined, + }, + ], + }); + }); + }); }); diff --git a/src/transforms/makeSimpleTransformSchema.ts b/src/transforms/makeSimpleTransformSchema.ts index bc564e778ba..4f40710f35a 100644 --- a/src/transforms/makeSimpleTransformSchema.ts +++ b/src/transforms/makeSimpleTransformSchema.ts @@ -18,6 +18,8 @@ export default function makeSimpleTransformSchema( transforms, mapping, ); - addResolveFunctionsToSchema(schema, resolvers); + addResolveFunctionsToSchema(schema, resolvers, { + allowResolversNotInSchema: true, + }); return schema; } diff --git a/src/transforms/transforms.ts b/src/transforms/transforms.ts index 459f40071a4..1f3a2b628b7 100644 --- a/src/transforms/transforms.ts +++ b/src/transforms/transforms.ts @@ -42,3 +42,18 @@ export function applyResultTransforms( originalResult, ); } + +export function composeTransforms(...transforms: Array): Transform { + const reverseTransforms = [...transforms].reverse(); + return { + transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return applySchemaTransforms(originalSchema, transforms); + }, + transformRequest(originalRequest: Request): Request { + return applyRequestTransforms(originalRequest, reverseTransforms); + }, + transformResult(result: Result): Result { + return applyResultTransforms(result, reverseTransforms); + }, + }; +} From 4a0413679e1b008c9d38f58d5223a3e1f8b2b976 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Thu, 1 Mar 2018 14:26:19 +0200 Subject: [PATCH 14/57] Separate transforms --- package.json | 2 +- src/test/testTransforms.ts | 103 +++------------------------------- src/transforms/FilterTypes.ts | 23 ++++++++ src/transforms/RenameTypes.ts | 75 +++++++++++++++++++++++++ src/transforms/index.ts | 4 ++ 5 files changed, 111 insertions(+), 96 deletions(-) create mode 100644 src/transforms/FilterTypes.ts create mode 100644 src/transforms/RenameTypes.ts diff --git a/package.json b/package.json index 565de261f7a..5a7f957f851 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.7", + "version": "3.0.0-alpha.8", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index d4cb193f1c4..2345cc10c5c 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -1,106 +1,17 @@ /* tslint:disable:no-unused-expression */ import { expect } from 'chai'; -import { - visit, - GraphQLSchema, - NamedTypeNode, - Kind, - GraphQLNamedType, - graphql, -} from 'graphql'; -import { Request, Result } from '../Interfaces'; -import { Transform } from '../transforms/transforms'; -import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; -import visitObject from '../transforms/visitObject'; +import { GraphQLSchema, GraphQLNamedType, graphql } from 'graphql'; import { propertySchema, bookingSchema } from './testingSchemas'; -import makeSimpleTransformSchema from '../transforms/makeSimpleTransformSchema'; - -function RenameTypes(renamer: (name: string) => string | undefined): Transform { - const reverseMap = {}; - return { - transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { - return visitSchema(originalSchema, { - [VisitSchemaKind.TYPE]( - type: GraphQLNamedType, - ): GraphQLNamedType | undefined { - const newName = renamer(type.name); - if (newName && newName !== type.name) { - reverseMap[newName] = type.name; - const newType = Object.assign(Object.create(type), type); - newType.name = newName; - return newType; - } - }, - - [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { - return undefined; - }, - }); - }, - - transformRequest(originalRequest: Request): Request { - const newDocument = visit(originalRequest.document, { - [Kind.NAMED_TYPE](node: NamedTypeNode): NamedTypeNode | undefined { - const name = node.name.value; - if (name in reverseMap) { - return { - ...node, - name: { - kind: Kind.NAME, - value: reverseMap[name], - }, - }; - } - }, - }); - return { - document: newDocument, - variables: originalRequest.variables, - }; - }, - - transformResult(result: Result): Result { - if (result.data) { - const newData = visitObject(result.data, (key, value) => { - if (key === '__typename') { - return renamer(value); - } - }); - const newResult = { - ...result, - data: newData, - }; - return newResult; - } - return result; - }, - }; -} - -function FilterTypes(filter: (type: GraphQLNamedType) => Boolean) { - return { - transformSchema(schema: GraphQLSchema): GraphQLSchema { - return visitSchema(schema, { - [VisitSchemaKind.TYPE](type: GraphQLNamedType): null | undefined { - if (filter(type)) { - return undefined; - } else { - return null; - } - }, - }); - }, - }; -} +import { Transforms, makeSimpleTransformSchema } from '../transforms'; describe('transforms', () => { describe('rename type', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - RenameTypes( - name => + Transforms.RenameTypes( + (name: string) => ({ Property: 'House', Location: 'Spots', @@ -160,7 +71,9 @@ describe('transforms', () => { describe('namespace', () => { let schema: GraphQLSchema; before(() => { - const transforms = [RenameTypes(name => `Property_${name}`)]; + const transforms = [ + Transforms.RenameTypes((name: string) => `Property_${name}`), + ]; schema = makeSimpleTransformSchema(propertySchema, transforms); }); it('should work', async () => { @@ -210,7 +123,7 @@ describe('transforms', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - FilterTypes(type => + Transforms.FilterTypes((type: GraphQLNamedType) => ['ID', 'String', 'DateTime', 'Query', 'Booking'].includes(type.name), ), ]; diff --git a/src/transforms/FilterTypes.ts b/src/transforms/FilterTypes.ts new file mode 100644 index 00000000000..b91ae7f1410 --- /dev/null +++ b/src/transforms/FilterTypes.ts @@ -0,0 +1,23 @@ +/* tslint:disable:no-unused-expression */ + +import { GraphQLSchema, GraphQLNamedType } from 'graphql'; +import { Transform } from '../transforms/transforms'; +import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; + +export default function FilterTypes( + filter: (type: GraphQLNamedType) => Boolean, +): Transform { + return { + transformSchema(schema: GraphQLSchema): GraphQLSchema { + return visitSchema(schema, { + [VisitSchemaKind.TYPE](type: GraphQLNamedType): null | undefined { + if (filter(type)) { + return undefined; + } else { + return null; + } + }, + }); + }, + }; +} diff --git a/src/transforms/RenameTypes.ts b/src/transforms/RenameTypes.ts new file mode 100644 index 00000000000..7cf8d860f29 --- /dev/null +++ b/src/transforms/RenameTypes.ts @@ -0,0 +1,75 @@ +import { + visit, + GraphQLSchema, + NamedTypeNode, + Kind, + GraphQLNamedType, +} from 'graphql'; +import { Request, Result } from '../Interfaces'; +import { Transform } from '../transforms/transforms'; +import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; +import visitObject from '../transforms/visitObject'; + +export default function RenameTypes( + renamer: (name: string) => string | undefined, +): Transform { + const reverseMap = {}; + return { + transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return visitSchema(originalSchema, { + [VisitSchemaKind.TYPE]( + type: GraphQLNamedType, + ): GraphQLNamedType | undefined { + const newName = renamer(type.name); + if (newName && newName !== type.name) { + reverseMap[newName] = type.name; + const newType = Object.assign(Object.create(type), type); + newType.name = newName; + return newType; + } + }, + + [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { + return undefined; + }, + }); + }, + + transformRequest(originalRequest: Request): Request { + const newDocument = visit(originalRequest.document, { + [Kind.NAMED_TYPE](node: NamedTypeNode): NamedTypeNode | undefined { + const name = node.name.value; + if (name in reverseMap) { + return { + ...node, + name: { + kind: Kind.NAME, + value: reverseMap[name], + }, + }; + } + }, + }); + return { + document: newDocument, + variables: originalRequest.variables, + }; + }, + + transformResult(result: Result): Result { + if (result.data) { + const newData = visitObject(result.data, (key, value) => { + if (key === '__typename') { + return renamer(value); + } + }); + const newResult = { + ...result, + data: newData, + }; + return newResult; + } + return result; + }, + }; +} diff --git a/src/transforms/index.ts b/src/transforms/index.ts index ac8aeb69089..3fd6e3d2b15 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -4,6 +4,8 @@ import ReplaceFieldWithFragment from './ReplaceFieldWithFragment'; import AddTypenameToAbstract from './AddTypenameToAbstract'; import FilterToSchema from './FilterToSchema'; import makeSimpleTransformSchema from './makeSimpleTransformSchema'; +import RenameTypes from './RenameTypes'; +import FilterTypes from './FilterTypes'; export * from './transforms'; export * from './visitSchema'; export { makeSimpleTransformSchema }; @@ -13,4 +15,6 @@ export const Transforms: { [name: string]: any } = { ReplaceFieldWithFragment, AddTypenameToAbstract, FilterToSchema, + RenameTypes, + FilterTypes, }; From 652d6083ec1b14894063f0a488649a99b640b4c8 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 6 Mar 2018 16:28:34 +0200 Subject: [PATCH 15/57] Better --- package.json | 4 +- src/Interfaces.ts | 15 ++ src/isSpecifiedScalarType.ts | 32 +++ src/stitching/delegateToSchema.ts | 4 +- src/stitching/makeRemoteExecutableSchema.ts | 74 ++---- src/stitching/mergeSchemas.ts | 60 ++++- src/stitching/resolvers.ts | 2 +- src/stitching/schemaRecreation.ts | 55 ++-- src/test/testAlternateMergeSchemas.ts | 246 ++++++++++++++++++ src/test/testTransforms.ts | 8 +- src/test/tests.ts | 1 + src/transforms/FilterRootFields.ts | 25 ++ src/transforms/RenameRootFields.ts | 31 +++ src/transforms/RenameTypes.ts | 15 ++ src/transforms/TransformRootFields.ts | 105 ++++++++ src/transforms/index.ts | 10 +- ...nsformSchema.ts => makeTransformSchema.ts} | 11 +- src/transforms/transforms.ts | 8 +- src/transforms/visitSchema.ts | 18 +- 19 files changed, 606 insertions(+), 118 deletions(-) create mode 100644 src/isSpecifiedScalarType.ts create mode 100644 src/test/testAlternateMergeSchemas.ts create mode 100644 src/transforms/FilterRootFields.ts create mode 100644 src/transforms/RenameRootFields.ts create mode 100644 src/transforms/TransformRootFields.ts rename src/transforms/{makeSimpleTransformSchema.ts => makeTransformSchema.ts} (61%) diff --git a/package.json b/package.json index 5a7f957f851..8f05b564953 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -59,7 +59,7 @@ }, "devDependencies": { "@types/chai": "4.0.10", - "@types/graphql": "0.11.7", + "@types/graphql": "^0.11.7", "@types/mocha": "^2.2.44", "@types/node": "^8.0.47", "@types/uuid": "^3.4.3", diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 0f669271edb..b77fb45a3b0 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -29,6 +29,12 @@ export interface IResolverOptions { __isTypeOf?: GraphQLIsTypeOfFn; } +export type Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (originalRequest: Request) => Request; + transformResult?: (result: Result) => Result; +}; + export type MergeInfo = { delegate: ( schemaName: string, @@ -39,6 +45,15 @@ export type MergeInfo = { info: GraphQLResolveInfo, ) => any; getSubSchema: (schemaName: string) => GraphQLSchema; + delegateToSchema: ( + schema: GraphQLSchema, + type: 'query' | 'mutation' | 'subscription', + fieldName: string, + args: { [key: string]: any }, + context: { [key: string]: any }, + info: GraphQLResolveInfo, + transforms?: Array, + ) => any; }; export type IFieldResolver = ( diff --git a/src/isSpecifiedScalarType.ts b/src/isSpecifiedScalarType.ts new file mode 100644 index 00000000000..0fec7d93544 --- /dev/null +++ b/src/isSpecifiedScalarType.ts @@ -0,0 +1,32 @@ +import { + GraphQLString, + GraphQLInt, + GraphQLFloat, + GraphQLBoolean, + GraphQLID, + GraphQLScalarType, + isNamedType, +} from 'graphql'; + +// FIXME: Replace with https://github.com/graphql/graphql-js/blob/master/src/type/scalars.js#L139 + +export const specifiedScalarTypes: Array = [ + GraphQLString, + GraphQLInt, + GraphQLFloat, + GraphQLBoolean, + GraphQLID, +]; + +export default function isSpecifiedScalarType(type: any): boolean { + return ( + isNamedType(type) && + // Would prefer to use specifiedScalarTypes.some(), however %checks needs + // a simple expression. + (type.name === GraphQLString.name || + type.name === GraphQLInt.name || + type.name === GraphQLFloat.name || + type.name === GraphQLBoolean.name || + type.name === GraphQLID.name) + ); +} diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 04bbda2d045..15476be061c 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -33,7 +33,7 @@ export default async function delegateToSchema( args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, - transforms: Array, + transforms?: Array, ): Promise { const rawDocument: DocumentNode = createDocument( targetField, @@ -51,7 +51,7 @@ export default async function delegateToSchema( }; transforms = [ - ...transforms, + ...(transforms || []), AddArgumentsAsVariables(targetSchema, args), FilterToSchema(targetSchema), AddTypenameToAbstract(targetSchema), diff --git a/src/stitching/makeRemoteExecutableSchema.ts b/src/stitching/makeRemoteExecutableSchema.ts index 29ec6b8889c..ee04597e683 100644 --- a/src/stitching/makeRemoteExecutableSchema.ts +++ b/src/stitching/makeRemoteExecutableSchema.ts @@ -19,13 +19,13 @@ import { buildSchema, printSchema, Kind, - ValueNode, GraphQLResolveInfo, } from 'graphql'; import linkToFetcher, { execute } from './linkToFetcher'; import isEmptyObject from '../isEmptyObject'; import { IResolvers, IResolverObject } from '../Interfaces'; import { makeExecutableSchema } from '../schemaGenerator'; +import { recreateType } from './schemaRecreation'; import resolveParentFromTypename from './resolveFromParentTypename'; import defaultMergedResolver from './defaultMergedResolver'; import { checkResultAndHandleErrors } from './errors'; @@ -114,7 +114,10 @@ export default function makeRemoteExecutableSchema({ const typeMap = schema.getTypeMap(); const types = Object.keys(typeMap).map(name => typeMap[name]); for (const type of types) { - if (type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType) { + if ( + type instanceof GraphQLInterfaceType || + type instanceof GraphQLUnionType + ) { resolvers[type.name] = { __resolveType(parent, context, info) { return resolveParentFromTypename(parent, info.schema); @@ -130,7 +133,11 @@ export default function makeRemoteExecutableSchema({ type === GraphQLInt ) ) { - resolvers[type.name] = createPassThroughScalar(type); + resolvers[type.name] = recreateType( + type, + (name: string) => null, + false, + ) as GraphQLScalarType; } } else if ( type instanceof GraphQLObjectType && @@ -155,7 +162,9 @@ export default function makeRemoteExecutableSchema({ function createResolver(fetcher: Fetcher): GraphQLFieldResolver { return async (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); + const fragments = Object.keys(info.fragments).map( + fragment => info.fragments[fragment], + ); const document = { kind: Kind.DOCUMENT, definitions: [info.operation, ...fragments], @@ -169,9 +178,14 @@ function createResolver(fetcher: Fetcher): GraphQLFieldResolver { }; } -function createSubscriptionResolver(name: string, link: ApolloLink): ResolverFn { +function createSubscriptionResolver( + name: string, + link: ApolloLink, +): ResolverFn { return (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); + const fragments = Object.keys(info.fragments).map( + fragment => info.fragments[fragment], + ); const document = { kind: Kind.DOCUMENT, definitions: [info.operation, ...fragments], @@ -188,51 +202,3 @@ function createSubscriptionResolver(name: string, link: ApolloLink): ResolverFn return observableToAsyncIterable(observable); }; } - -function createPassThroughScalar({ - name, - description, -}: { - name: string; - description: string; -}): GraphQLScalarType { - return new GraphQLScalarType({ - name: name, - description: description, - serialize(value) { - return value; - }, - parseValue(value) { - return value; - }, - parseLiteral(ast) { - return parseLiteral(ast); - }, - }); -} - -function parseLiteral(ast: ValueNode): any { - switch (ast.kind) { - case Kind.STRING: - case Kind.BOOLEAN: { - return ast.value; - } - case Kind.INT: - case Kind.FLOAT: { - return parseFloat(ast.value); - } - case Kind.OBJECT: { - const value = Object.create(null); - ast.fields.forEach(field => { - value[field.name.value] = parseLiteral(field.value); - }); - - return value; - } - case Kind.LIST: { - return ast.values.map(parseLiteral); - } - default: - return null; - } -} diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 96a39e9fa50..87390ddc970 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -36,7 +36,7 @@ import { } from './schemaRecreation'; import delegateToSchema from './delegateToSchema'; import typeFromAST, { GetType } from './typeFromAST'; -import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; +import { Transform, Transforms } from '../transforms'; import mergeDeep from '../mergeDeep'; export type OnTypeConflict = ( @@ -59,7 +59,10 @@ export default function mergeSchemas({ onTypeConflict, resolvers, }: { - schemas: Array<{ name: string; schema: string | GraphQLSchema }>; + schemas: Array<{ + name: string; + schema: string | GraphQLSchema | Array; + }>; onTypeConflict?: OnTypeConflict; resolvers?: Array | IResolvers; }): GraphQLSchema { @@ -86,7 +89,10 @@ export function mergeSchemasImplementation({ visitType, resolvers, }: { - schemas: Array<{ name: string; schema: string | GraphQLSchema }>; + schemas: Array<{ + name: string; + schema: string | GraphQLSchema | Array; + }>; visitType?: VisitType; resolvers?: Array | IResolvers; }): GraphQLSchema { @@ -139,11 +145,13 @@ export function mergeSchemasImplementation({ const queryType = schema.getQueryType(); const mutationType = schema.getMutationType(); const subscriptionType = schema.getSubscriptionType(); - addTypeCandidate(typeCandidates, 'Query', { - schemaName: subSchema.name, - schema, - type: queryType, - }); + if (queryType) { + addTypeCandidate(typeCandidates, 'Query', { + schemaName: subSchema.name, + schema, + type: queryType, + }); + } if (mutationType) { addTypeCandidate(typeCandidates, 'Mutation', { schemaName: subSchema.name, @@ -194,6 +202,13 @@ export function mergeSchemasImplementation({ if (extensionsDocument.definitions.length > 0) { extensions.push(extensionsDocument); } + } else if (Array.isArray(subSchema.schema)) { + subSchema.schema.forEach(type => { + addTypeCandidate(typeCandidates, type.name, { + schemaName: subSchema.name, + type: type, + }); + }); } else { throw new Error(`Invalid schema ${subSchema.name}`); } @@ -219,7 +234,7 @@ export function mergeSchemasImplementation({ } else { throw new Error('Invalid `visitType` result for type "${typeName}"'); } - types[typeName] = recreateType(type, resolveType); + types[typeName] = recreateType(type, resolveType, false); if (typeResolvers) { generatedResolvers[typeName] = typeResolvers; } @@ -309,7 +324,7 @@ function createMergeInfo( In version 3.0, \`delegate\` requires a schema name as a first argument, have you updated your code?`); } const schema = schemas[schemaName]; - const fragmentTransform = ReplaceFieldWithFragment( + const fragmentTransform = Transforms.ReplaceFieldWithFragment( schema, fragmentReplacements, ); @@ -326,6 +341,29 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo [fragmentTransform], ); }, + delegateToSchema( + schema: GraphQLSchema, + operation: 'query' | 'mutation' | 'subscription', + fieldName: string, + args: { [key: string]: any }, + context: { [key: string]: any }, + info: GraphQLResolveInfo, + transforms?: Array, + ) { + const fragmentTransform = Transforms.ReplaceFieldWithFragment( + schema, + fragmentReplacements, + ); + return delegateToSchema( + schema, + operation, + fieldName, + args, + context, + info, + [fragmentTransform, ...(transforms || [])], + ); + }, }; } @@ -472,7 +510,7 @@ const defaultVisitType = ( }); const type = new GraphQLObjectType({ name, - fields: fieldMapToFieldConfigMap(fields, resolveType), + fields: fieldMapToFieldConfigMap(fields, resolveType, false), }); return { type, diff --git a/src/stitching/resolvers.ts b/src/stitching/resolvers.ts index fc89a9e0955..88020a6cd17 100644 --- a/src/stitching/resolvers.ts +++ b/src/stitching/resolvers.ts @@ -64,7 +64,7 @@ export function generateSimpleMapping(targetSchema: GraphQLSchema): Mapping { return result; } -function generateMappingFromObjectType( +export function generateMappingFromObjectType( type: GraphQLObjectType, operation: Operation, ): { diff --git a/src/stitching/schemaRecreation.ts b/src/stitching/schemaRecreation.ts index 78b9c8766ca..8ee083bcf7f 100644 --- a/src/stitching/schemaRecreation.ts +++ b/src/stitching/schemaRecreation.ts @@ -1,28 +1,23 @@ import { GraphQLArgument, GraphQLArgumentConfig, - GraphQLBoolean, GraphQLEnumType, GraphQLField, GraphQLFieldConfig, GraphQLFieldConfigArgumentMap, GraphQLFieldConfigMap, GraphQLFieldMap, - GraphQLFloat, - GraphQLID, GraphQLInputField, GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, GraphQLInputFieldMap, GraphQLInputObjectType, - GraphQLInt, GraphQLInterfaceType, GraphQLList, GraphQLNamedType, GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, - GraphQLString, GraphQLType, GraphQLUnionType, Kind, @@ -30,6 +25,7 @@ import { getNamedType, isNamedType, } from 'graphql'; +import isSpecifiedScalarType from '../isSpecifiedScalarType'; import { ResolveType } from '../Interfaces'; import resolveFromParentTypename from './resolveFromParentTypename'; import defaultMergedResolver from './defaultMergedResolver'; @@ -37,6 +33,7 @@ import defaultMergedResolver from './defaultMergedResolver'; export function recreateType( type: GraphQLNamedType, resolveType: ResolveType, + keepResolvers: Boolean, ): GraphQLNamedType { if (type instanceof GraphQLObjectType) { const fields = type.getFields(); @@ -46,7 +43,9 @@ export function recreateType( name: type.name, description: type.description, astNode: type.astNode, - fields: () => fieldMapToFieldConfigMap(fields, resolveType), + isTypeOf: keepResolvers ? type.isTypeOf : undefined, + fields: () => + fieldMapToFieldConfigMap(fields, resolveType, keepResolvers), interfaces: () => interfaces.map(iface => resolveType(iface)), }); } else if (type instanceof GraphQLInterfaceType) { @@ -56,9 +55,12 @@ export function recreateType( name: type.name, description: type.description, astNode: type.astNode, - fields: () => fieldMapToFieldConfigMap(fields, resolveType), - resolveType: (parent, context, info) => - resolveFromParentTypename(parent, info.schema), + fields: () => + fieldMapToFieldConfigMap(fields, resolveType, keepResolvers), + resolveType: keepResolvers + ? type.resolveType + : (parent, context, info) => + resolveFromParentTypename(parent, info.schema), }); } else if (type instanceof GraphQLUnionType) { return new GraphQLUnionType({ @@ -67,8 +69,10 @@ export function recreateType( astNode: type.astNode, types: () => type.getTypes().map(unionMember => resolveType(unionMember)), - resolveType: (parent, context, info) => - resolveFromParentTypename(parent, info.schema), + resolveType: keepResolvers + ? type.resolveType + : (parent, context, info) => + resolveFromParentTypename(parent, info.schema), }); } else if (type instanceof GraphQLInputObjectType) { return new GraphQLInputObjectType({ @@ -92,13 +96,7 @@ export function recreateType( values: newValues, }); } else if (type instanceof GraphQLScalarType) { - if ( - type === GraphQLID || - type === GraphQLString || - type === GraphQLFloat || - type === GraphQLBoolean || - type === GraphQLInt - ) { + if (isSpecifiedScalarType(type)) { return type; } else { return new GraphQLScalarType({ @@ -150,13 +148,18 @@ function parseLiteral(ast: ValueNode): any { export function fieldMapToFieldConfigMap( fields: GraphQLFieldMap, resolveType: ResolveType, + keepResolvers: Boolean, ): GraphQLFieldConfigMap { const result: GraphQLFieldConfigMap = {}; Object.keys(fields).forEach(name => { const field = fields[name]; const type = resolveType(field.type); if (type !== null) { - result[name] = fieldToFieldConfig(fields[name], resolveType); + result[name] = fieldToFieldConfig( + fields[name], + resolveType, + keepResolvers, + ); } }); return result; @@ -189,21 +192,23 @@ export function createResolveType( return resolveType; } -function fieldToFieldConfig( +export function fieldToFieldConfig( field: GraphQLField, resolveType: ResolveType, + keepResolvers: Boolean, ): GraphQLFieldConfig { return { type: resolveType(field.type), args: argsToFieldConfigArgumentMap(field.args, resolveType), - resolve: defaultMergedResolver, + resolve: keepResolvers ? field.resolve : defaultMergedResolver, + subscribe: keepResolvers ? field.subscribe : null, description: field.description, deprecationReason: field.deprecationReason, astNode: field.astNode, }; } -function argsToFieldConfigArgumentMap( +export function argsToFieldConfigArgumentMap( args: Array, resolveType: ResolveType, ): GraphQLFieldConfigArgumentMap { @@ -217,7 +222,7 @@ function argsToFieldConfigArgumentMap( return result; } -function argumentToArgumentConfig( +export function argumentToArgumentConfig( argument: GraphQLArgument, resolveType: ResolveType, ): [string, GraphQLArgumentConfig] | null { @@ -236,7 +241,7 @@ function argumentToArgumentConfig( } } -function inputFieldMapToFieldConfigMap( +export function inputFieldMapToFieldConfigMap( fields: GraphQLInputFieldMap, resolveType: ResolveType, ): GraphQLInputFieldConfigMap { @@ -251,7 +256,7 @@ function inputFieldMapToFieldConfigMap( return result; } -function inputFieldToFieldConfig( +export function inputFieldToFieldConfig( field: GraphQLInputField, resolveType: ResolveType, ): GraphQLInputFieldConfig { diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts new file mode 100644 index 00000000000..bdb9239813c --- /dev/null +++ b/src/test/testAlternateMergeSchemas.ts @@ -0,0 +1,246 @@ +/* tslint:disable:no-unused-expression */ + +import { expect } from 'chai'; +import { graphql, GraphQLSchema } from 'graphql'; +import mergeSchemas from '../stitching/mergeSchemas'; +import { Transforms, makeTransformSchema } from '../transforms'; +import { propertySchema, bookingSchema } from './testingSchemas'; + +let linkSchema = ` + """ + A new type linking the Property type. + """ + type LinkType { + test: String + """ + The property. + """ + property: Properties_Property + } + + interface Node { + id: ID! + } + + extend type Bookings_Booking implements Node { + """ + The property of the booking. + """ + property: Properties_Property + } + + extend type Properties_Property implements Node { + """ + A list of bookings. + """ + bookings( + """ + The maximum number of bookings to retrieve. + """ + limit: Int + ): [Bookings_Booking] + } + + extend type Query { + linkTest: LinkType + node(id: ID!): Node + nodes: [Node] + } + + extend type Bookings_Customer implements Node +`; + +describe('merge schemas through transforms', () => { + let mergedSchema: GraphQLSchema; + + before(async () => { + // namespace and strip schemas + const transformedPropertySchema = makeTransformSchema(propertySchema, [ + Transforms.FilterRootFields((operation: string, rootField: string) => + ['Query.properties'].includes(`${operation}.${rootField}`), + ), + Transforms.RenameTypes((name: string) => `Properties_${name}`), + Transforms.RenameRootFields((name: string) => `Properties_${name}`), + ]); + const transformedBookingSchema = makeTransformSchema(bookingSchema, [ + Transforms.FilterRootFields((operation: string, rootField: string) => + ['Query.bookings'].includes(`${operation}.${rootField}`), + ), + Transforms.RenameTypes((name: string) => `Bookings_${name}`), + Transforms.RenameRootFields( + (operation: string, name: string) => `Bookings_${name}`, + ), + ]); + + mergedSchema = mergeSchemas({ + schemas: [ + { + name: 'Property', + schema: transformedPropertySchema, + }, + { + name: 'Booking', + schema: transformedBookingSchema, + }, + { + name: 'LinkSchema', + schema: linkSchema, + }, + ], + resolvers: { + Query: { + // delegating directly, no subschemas or mergeInfo + node(parent, args, context, info) { + if (args.id.startsWith('p')) { + return info.mergeInfo.delegateToSchema( + propertySchema, + 'query', + 'propertyById', + args, + context, + info, + transformedPropertySchema.transforms, + ); + } else if (args.id.startsWith('b')) { + return info.mergeInfo.delegateToSchema( + bookingSchema, + 'query', + 'bookingById', + args, + context, + info, + transformedBookingSchema.transforms, + ); + } else if (args.id.startsWith('c')) { + return info.mergeInfo.delegateToSchema( + bookingSchema, + 'query', + 'customerById', + args, + context, + info, + transformedBookingSchema.transforms, + ); + } else { + throw new Error('invalid id'); + } + }, + }, + Properties_Property: { + bookings: { + fragment: 'fragment PropertyFragment on Property { id }', + resolve(parent, args, context, info) { + return info.mergeInfo.delegateToSchema( + bookingSchema, + 'query', + 'bookingsByPropertyId', + { + propertyId: parent.id, + limit: args.limit ? args.limit : null, + }, + context, + info, + transformedBookingSchema.transforms, + ); + }, + }, + }, + Bookings_Booking: { + property: { + fragment: 'fragment BookingFragment on Booking { propertyId }', + resolve(parent, args, context, info) { + return info.mergeInfo.delegateToSchema( + propertySchema, + 'query', + 'propertyById', + { + id: parent.propertyId, + }, + context, + info, + transformedPropertySchema.transforms, + ); + }, + }, + }, + }, + }); + }); + + // FIXME fragemnt replacements + it('node should work', async () => { + const result = await graphql( + mergedSchema, + ` + query($pid: ID!, $bid: ID!) { + property: node(id: $pid) { + __typename + id + ... on Properties_Property { + id + name + bookings { + startTime + endTime + } + } + } + booking: node(id: $bid) { + __typename + id + ... on Bookings_Booking { + startTime + endTime + propertyId + property { + id + name + } + } + } + } + `, + {}, + {}, + { + pid: 'p1', + bid: 'b1', + }, + ); + + expect(result).to.deep.equal({ + data: { + booking: { + __typename: 'Bookings_Booking', + endTime: '2016-06-03', + id: 'b1', + property: { + id: 'p1', + name: 'Super great hotel', + }, + propertyId: 'p1', + startTime: '2016-05-04', + }, + property: { + __typename: 'Properties_Property', + bookings: [ + { + endTime: '2016-06-03', + startTime: '2016-05-04', + }, + { + endTime: '2016-07-03', + startTime: '2016-06-04', + }, + { + endTime: '2016-09-03', + startTime: '2016-08-04', + }, + ], + id: 'p1', + name: 'Super great hotel', + }, + }, + }); + }); +}); diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index 2345cc10c5c..1a0f702515c 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { GraphQLSchema, GraphQLNamedType, graphql } from 'graphql'; import { propertySchema, bookingSchema } from './testingSchemas'; -import { Transforms, makeSimpleTransformSchema } from '../transforms'; +import { Transforms, makeTransformSchema } from '../transforms'; describe('transforms', () => { describe('rename type', () => { @@ -23,7 +23,7 @@ describe('transforms', () => { }[name]), ), ]; - schema = makeSimpleTransformSchema(propertySchema, transforms); + schema = makeTransformSchema(propertySchema, transforms); }); it('should work', async () => { const result = await graphql( @@ -74,7 +74,7 @@ describe('transforms', () => { const transforms = [ Transforms.RenameTypes((name: string) => `Property_${name}`), ]; - schema = makeSimpleTransformSchema(propertySchema, transforms); + schema = makeTransformSchema(propertySchema, transforms); }); it('should work', async () => { const result = await graphql( @@ -127,7 +127,7 @@ describe('transforms', () => { ['ID', 'String', 'DateTime', 'Query', 'Booking'].includes(type.name), ), ]; - schema = makeSimpleTransformSchema(bookingSchema, transforms); + schema = makeTransformSchema(bookingSchema, transforms); }); it('should work normally', async () => { diff --git a/src/test/tests.ts b/src/test/tests.ts index b767315c5db..9fb050c1e72 100755 --- a/src/test/tests.ts +++ b/src/test/tests.ts @@ -7,3 +7,4 @@ import './testResolution'; import './testMakeRemoteExecutableSchema'; import './testMergeSchemas'; import './testTransforms'; +import './testAlternateMergeSchemas'; diff --git a/src/transforms/FilterRootFields.ts b/src/transforms/FilterRootFields.ts new file mode 100644 index 00000000000..244d7c8a775 --- /dev/null +++ b/src/transforms/FilterRootFields.ts @@ -0,0 +1,25 @@ +import { GraphQLField } from 'graphql'; +import { Transform } from './transforms'; +import TransformRootFields from './TransformRootFields'; + +export type RootFilter = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => Boolean; + +export default function FilterRootFields(filter: RootFilter): Transform { + return TransformRootFields( + ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, + ) => { + if (filter(operation, fieldName, field)) { + return undefined; + } else { + return null; + } + }, + ); +} diff --git a/src/transforms/RenameRootFields.ts b/src/transforms/RenameRootFields.ts new file mode 100644 index 00000000000..f31d2529b83 --- /dev/null +++ b/src/transforms/RenameRootFields.ts @@ -0,0 +1,31 @@ +import { GraphQLNamedType, GraphQLField } from 'graphql'; +import { Transform } from './transforms'; +import { + createResolveType, + fieldToFieldConfig, +} from '../stitching/schemaRecreation'; +import TransformRootFields from './TransformRootFields'; + +export default function RenameRootFields( + renamer: ( + operation: 'Query' | 'Mutation' | 'Subscription', + name: string, + field: GraphQLField, + ) => string, +): Transform { + const resolveType = createResolveType( + (name: string, type: GraphQLNamedType): GraphQLNamedType => type, + ); + return TransformRootFields( + ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, + ) => { + return { + name: renamer(operation, fieldName, field), + field: fieldToFieldConfig(field, resolveType, true), + }; + }, + ); +} diff --git a/src/transforms/RenameTypes.ts b/src/transforms/RenameTypes.ts index 7cf8d860f29..864c92072ff 100644 --- a/src/transforms/RenameTypes.ts +++ b/src/transforms/RenameTypes.ts @@ -4,22 +4,37 @@ import { NamedTypeNode, Kind, GraphQLNamedType, + GraphQLScalarType, } from 'graphql'; +import isSpecifiedScalarType from '../isSpecifiedScalarType'; import { Request, Result } from '../Interfaces'; import { Transform } from '../transforms/transforms'; import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; import visitObject from '../transforms/visitObject'; +export type RenameOptions = { + renameBuiltins: Boolean; + renameScalars: Boolean; +}; + export default function RenameTypes( renamer: (name: string) => string | undefined, + options?: RenameOptions, ): Transform { const reverseMap = {}; + const { renameBuiltins = false, renameScalars = true } = options || {}; return { transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { return visitSchema(originalSchema, { [VisitSchemaKind.TYPE]( type: GraphQLNamedType, ): GraphQLNamedType | undefined { + if (isSpecifiedScalarType(type) && !renameBuiltins) { + return undefined; + } + if (type instanceof GraphQLScalarType && !renameScalars) { + return undefined; + } const newName = renamer(type.name); if (newName && newName !== type.name) { reverseMap[newName] = type.name; diff --git a/src/transforms/TransformRootFields.ts b/src/transforms/TransformRootFields.ts new file mode 100644 index 00000000000..b005ae604fa --- /dev/null +++ b/src/transforms/TransformRootFields.ts @@ -0,0 +1,105 @@ +import { + GraphQLObjectType, + GraphQLSchema, + GraphQLNamedType, + GraphQLField, + GraphQLFieldConfig, +} from 'graphql'; +import isEmptyObject from '../isEmptyObject'; +import { Transform } from './transforms'; +import { visitSchema, VisitSchemaKind } from './visitSchema'; +import { + createResolveType, + fieldToFieldConfig, +} from '../stitching/schemaRecreation'; + +export type RootTransformer = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | undefined; + +export default function TransformRootFields( + transform: RootTransformer, +): Transform { + return { + transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return visitSchema(originalSchema, { + [VisitSchemaKind.QUERY](type: GraphQLObjectType) { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + transform('Query', fieldName, field), + ); + }, + [VisitSchemaKind.MUTATION](type: GraphQLObjectType) { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + transform('Mutation', fieldName, field), + ); + }, + [VisitSchemaKind.SUBSCRIPTION](type: GraphQLObjectType) { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + transform('Subscription', fieldName, field), + ); + }, + }); + }, + }; +} + +function transformFields( + type: GraphQLObjectType, + transformer: ( + fieldName: string, + field: GraphQLField, + ) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | undefined, +): GraphQLObjectType { + const resolveType = createResolveType( + (name: string, originalType: GraphQLNamedType): GraphQLNamedType => + originalType, + ); + const fields = type.getFields(); + const newFields = {}; + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + const newField = transformer(fieldName, field); + if (typeof newField === 'undefined') { + newFields[fieldName] = fieldToFieldConfig(field, resolveType, true); + } else if (newField !== null) { + if ( + (<{ name: string; field: GraphQLFieldConfig }>newField).name + ) { + newFields[ + (<{ name: string; field: GraphQLFieldConfig }>newField).name + ] = (<{ + name: string; + field: GraphQLFieldConfig; + }>newField).field; + } else { + newFields[fieldName] = newField; + } + } + }); + if (isEmptyObject(newFields)) { + return null; + } else { + return new GraphQLObjectType({ + name: type.name, + description: type.description, + astNode: type.astNode, + fields: newFields, + }); + } +} diff --git a/src/transforms/index.ts b/src/transforms/index.ts index 3fd6e3d2b15..0b26e040b91 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -3,12 +3,15 @@ import CheckResultAndHandleErrors from './CheckResultAndHandleErrors'; import ReplaceFieldWithFragment from './ReplaceFieldWithFragment'; import AddTypenameToAbstract from './AddTypenameToAbstract'; import FilterToSchema from './FilterToSchema'; -import makeSimpleTransformSchema from './makeSimpleTransformSchema'; +import makeTransformSchema from './makeTransformSchema'; import RenameTypes from './RenameTypes'; import FilterTypes from './FilterTypes'; +import TransformRootFields from './TransformRootFields'; +import RenameRootFields from './RenameRootFields'; +import FilterRootFields from './FilterRootFields'; export * from './transforms'; export * from './visitSchema'; -export { makeSimpleTransformSchema }; +export { makeTransformSchema }; export const Transforms: { [name: string]: any } = { AddArgumentsAsVariables, CheckResultAndHandleErrors, @@ -17,4 +20,7 @@ export const Transforms: { [name: string]: any } = { FilterToSchema, RenameTypes, FilterTypes, + TransformRootFields, + RenameRootFields, + FilterRootFields, }; diff --git a/src/transforms/makeSimpleTransformSchema.ts b/src/transforms/makeTransformSchema.ts similarity index 61% rename from src/transforms/makeSimpleTransformSchema.ts rename to src/transforms/makeTransformSchema.ts index 4f40710f35a..cc00fbcd49f 100644 --- a/src/transforms/makeSimpleTransformSchema.ts +++ b/src/transforms/makeTransformSchema.ts @@ -1,17 +1,18 @@ import { GraphQLSchema } from 'graphql'; import { addResolveFunctionsToSchema } from '../schemaGenerator'; +import { visitSchema } from '../transforms/visitSchema'; import { Transform, applySchemaTransforms } from '../transforms/transforms'; import { generateProxyingResolvers, generateSimpleMapping, } from '../stitching/resolvers'; -export default function makeSimpleTransformSchema( +export default function makeTransformSchema( targetSchema: GraphQLSchema, transforms: Array, -) { - const schema = applySchemaTransforms(targetSchema, transforms); +): GraphQLSchema & { transforms: Array } { + let schema = visitSchema(targetSchema, {}, true); const mapping = generateSimpleMapping(targetSchema); const resolvers = generateProxyingResolvers( targetSchema, @@ -21,5 +22,7 @@ export default function makeSimpleTransformSchema( addResolveFunctionsToSchema(schema, resolvers, { allowResolversNotInSchema: true, }); - return schema; + schema = applySchemaTransforms(schema, transforms); + (schema as any).transforms = transforms; + return schema as GraphQLSchema & { transforms: Array }; } diff --git a/src/transforms/transforms.ts b/src/transforms/transforms.ts index 1f3a2b628b7..7a84cb1bfe1 100644 --- a/src/transforms/transforms.ts +++ b/src/transforms/transforms.ts @@ -1,11 +1,7 @@ import { GraphQLSchema } from 'graphql'; -import { Request, Result } from '../Interfaces'; +import { Request, Result, Transform } from '../Interfaces'; -export type Transform = { - transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; - transformRequest?: (originalRequest: Request) => Request; - transformResult?: (result: Result) => Result; -}; +export { Transform }; export function applySchemaTransforms( originalSchema: GraphQLSchema, diff --git a/src/transforms/visitSchema.ts b/src/transforms/visitSchema.ts index 1c6e7fec1b8..3f92aeabc4e 100644 --- a/src/transforms/visitSchema.ts +++ b/src/transforms/visitSchema.ts @@ -35,7 +35,11 @@ export type TypeVisitor = ( schema: GraphQLSchema, ) => GraphQLNamedType; -export function visitSchema(schema: GraphQLSchema, visitor: SchemaVisitor) { +export function visitSchema( + schema: GraphQLSchema, + visitor: SchemaVisitor, + stripResolvers?: Boolean, +) { const types = {}; const resolveType = createResolveType(name => { if (typeof types[name] === 'undefined') { @@ -58,24 +62,24 @@ export function visitSchema(schema: GraphQLSchema, visitor: SchemaVisitor) { schema, ); if (typeof result === 'undefined') { - types[typeName] = recreateType(type, resolveType); + types[typeName] = recreateType(type, resolveType, !stripResolvers); } else if (result === null) { types[typeName] = null; } else { - types[typeName] = recreateType(result, resolveType); + types[typeName] = recreateType(result, resolveType, !stripResolvers); } } else { - types[typeName] = recreateType(type, resolveType); + types[typeName] = recreateType(type, resolveType, !stripResolvers); } } }); return new GraphQLSchema({ - query: queryType ? types[queryType.name] as GraphQLObjectType : null, + query: queryType ? (types[queryType.name] as GraphQLObjectType) : null, mutation: mutationType - ? types[mutationType.name] as GraphQLObjectType + ? (types[mutationType.name] as GraphQLObjectType) : null, subscription: subscriptionType - ? types[subscriptionType.name] as GraphQLObjectType + ? (types[subscriptionType.name] as GraphQLObjectType) : null, types: Object.keys(types).map(name => types[name]), }); From 0988b013398e2c556a02f9859ec334198eb5711a Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 13 Mar 2018 14:54:56 +0200 Subject: [PATCH 16/57] Fixed fragments --- src/stitching/mergeSchemas.ts | 10 +++++----- src/test/testAlternateMergeSchemas.ts | 7 ------- src/transforms/ReplaceFieldWithFragment.ts | 1 - 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 87390ddc970..ba1b937c2db 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -262,10 +262,10 @@ export function mergeSchemasImplementation({ Object.keys(type).forEach(fieldName => { const field = type[fieldName]; if (field.fragment) { - fragments[typeName] = fragments[typeName] || {}; - fragments[typeName][fieldName] = parseFragmentToInlineFragment( - field.fragment, - ); + const parsedFragment = parseFragmentToInlineFragment(field.fragment); + const actualTypeName = parsedFragment.typeCondition.name.value; + fragments[actualTypeName] = fragments[actualTypeName] || {}; + fragments[actualTypeName][fieldName] = parsedFragment; } }); }); @@ -361,7 +361,7 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo args, context, info, - [fragmentTransform, ...(transforms || [])], + [...(transforms || []), fragmentTransform], ); }, }; diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index bdb9239813c..f3d5a906afd 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -175,9 +175,7 @@ describe('merge schemas through transforms', () => { query($pid: ID!, $bid: ID!) { property: node(id: $pid) { __typename - id ... on Properties_Property { - id name bookings { startTime @@ -187,11 +185,9 @@ describe('merge schemas through transforms', () => { } booking: node(id: $bid) { __typename - id ... on Bookings_Booking { startTime endTime - propertyId property { id name @@ -213,12 +209,10 @@ describe('merge schemas through transforms', () => { booking: { __typename: 'Bookings_Booking', endTime: '2016-06-03', - id: 'b1', property: { id: 'p1', name: 'Super great hotel', }, - propertyId: 'p1', startTime: '2016-05-04', }, property: { @@ -237,7 +231,6 @@ describe('merge schemas through transforms', () => { startTime: '2016-08-04', }, ], - id: 'p1', name: 'Super great hotel', }, }, diff --git a/src/transforms/ReplaceFieldWithFragment.ts b/src/transforms/ReplaceFieldWithFragment.ts index d6229a20a05..8ca1c3075c6 100644 --- a/src/transforms/ReplaceFieldWithFragment.ts +++ b/src/transforms/ReplaceFieldWithFragment.ts @@ -50,7 +50,6 @@ function replaceFieldsWithFragments( const parentType: GraphQLType = typeInfo.getParentType(); if (parentType) { const parentTypeName = parentType.name; - let selections = node.selections; if (mapping[parentTypeName]) { From d0f8d3d72d81dfa28c096f0d08a88fce81b862d0 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 13 Mar 2018 14:55:24 +0200 Subject: [PATCH 17/57] Alpha 10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8f05b564953..9b6b7830f08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.9", + "version": "3.0.0-alpha.10", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From 08caa78e319fbd40175d518e6342df5e6877a542 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Thu, 15 Mar 2018 15:36:50 +0200 Subject: [PATCH 18/57] Starting doing the docs --- docs/_config.yml | 6 +- docs/source/schema-delegation.md | 189 +++++++++++++++++++++++++++++++ docs/source/schema-stitching.md | 4 + docs/source/schema-transforms.md | 8 ++ 4 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 docs/source/schema-delegation.md create mode 100644 docs/source/schema-transforms.md diff --git a/docs/_config.yml b/docs/_config.yml index f482c49ab17..8f7a7691b8b 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -17,9 +17,11 @@ sidebar_categories: - scalars - mocking - connectors + - schema-delegation - remote-schemas - - schema-directives + - schema-transforms - schema-stitching + - schema-directives Related: - title: Apollo Server href: https://www.apollographql.com/docs/apollo-server/ @@ -41,4 +43,4 @@ apis: url: https://www.apollographql.com/docs/graphql-tools/ root: /docs/graphql-tools/ -public_dir: public/docs/graphql-tools \ No newline at end of file +public_dir: public/docs/graphql-tools diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md new file mode 100644 index 00000000000..f0b49b932b2 --- /dev/null +++ b/docs/source/schema-delegation.md @@ -0,0 +1,189 @@ +--- +title: Schema delegation +description: Forward queries to other schemas automatically +--- + +Schema delegation is a way to automatically forward query or a part of the query from the schema one is executing now to another schema called `subschema`. Delegation is useful when your parent schema shares big part of its model with the subschema. This is often the case when, eg, building a GraphQL gateway by connecting multiple schemas together. Several useful tools exist to work together with delegation in `graphql-tools`: + +* [Remote schemas](./remote-schemas.html) - turning a remote GraphQL endpoint into a local GraphQL schema +* [Schema transforms](./schema-transforms.html) - manipulating with schemas, while keep ability to delegate to them +* [Schema stitching](./schema-stitching) - extending schemas and connecting multiple schemas together + +Delegation is performed by one function - `delegateToSchema`. It should be called from within a resolver and it uses the GraphQL query part that starts at resolver part to create a query that will be executed on a subschema. + +

Motivational example

+ +Let's consider our schemas, a subschema and a parent schema that reuses parts of a subschema. While parent schema reuses the *definitions* of the subschema, we want to keep implementation separate. This way the subschema can be tested and used separately or even be a remote service. + +```graphql +# Subschema +type Repository { + id: ID! + url: String + issues: [Issue] + userId: ID! +} + +type Issue { + id: ID! + text: String! + repository: Repository! +} + +type Query { + repositoryById(id: ID!): Repository + repositoriesByUserId(id: ID!): [Repository] +} + +# Schema +type Repository { + id: ID! + url: String + issues: [Issue] + userId: ID! + user: User +} + +type Issue { + id: ID! + text: String! + repository: Repository! +} + +type User { + id: ID! + username: String + repositories: [Repository] +} + +type Query { + userById(id: ID!): User +} +``` + +We want parent schema to delegate retrieval of repositories to the subschema. Assuming a query as following: + +```graphql +query { + userById(id: "1") { + id + username + repositories { + id + url + user + issues { + text + } + } + } +} +``` + +At resolver for `repositories`, we would delegate. While it's possible to simply call a graphql endpoint of this schema or execute manually, this would require us to manually convert the query or always fetch all possible fields, which could lead to overfetching. Delegation automatically extracts the query. + +```graphql +# To the subschema +query($id: ID!) { + repositoriesByUserId(id: $id) { + id + url + issues { + text + } + } +} +``` + +Delegation also removes the fields that don't exist on the subschema, such as user. This field would be retrieved on our parent schema using normal GraphQL resolvers. + +

API

+ +

delegateToSchema

+ +``` +function delegateToSchema( + targetSchema: GraphQLSchema, + targetOperation: 'query' | 'mutation' | 'subscription', + targetField: string, + args: { [key: string]: any }, + context: { [key: string]: any }, + info: GraphQLResolveInfo, + transforms?: Array, +): Promise +``` + +#### targetSchema: GraphQLSchema + +A subschema to delegate to. + +#### targetOperation: 'query' | 'mutation' | 'subscription' + +An operation to use during the delegation. + +#### targetField: string + +A root field in a subschema from which the query should start. + +#### args: { [key: string]: any } + +Additional arguments to be passed to the field. Arguments on the field that is being resolved are going to be kept if they are valid, this allows adding additional arguments or overriding them. For example: + +```graphql +# Subschema + +type Booking { + id: ID! +} + +type Query { + bookingsByUser(userId: ID!, limit: Int): [Booking] +} + +# Schema + +type User { + id: ID! + bookings(limit: Int): [Booking] +} + +type Booking { + id: ID! +} +``` + +If we are to delegate at `User.bookings` to `bookingsByUser`, we want to preserve the `limit` argument and add an `userId` argument by using the `User.id`. So the resolrver would look like the following: + +```js +bookings(parent, args, context, info) { + return delegateToSchema( + subschema, + 'query', + 'bookingsByUser', + { + userId: parent.id, + }, + context, + info, + ); +} +``` + +#### context: { [key: string]: any } + +GraphQL context that is going to be past to subschema execution or subsciption call. + +#### info: GraphQLResolveInfo + +GraphQL resolve info of the current resolver. Used to get the query that starts at the current resolver + +#### transforms: Array + +[Transforms](./transforms.html) to apply to the query and results. Should be the +same transformed that were used to transform the schema, if any. One can use `transformedSchema.transforms` to retrieve transforms. + +

Additional considerations

+ +### Aliases + +Delegation preserves aliases that are passed from the parent query. However that presents problems, because default GraphQL resolvers retrieve field from parent based on their name, not aliases. This way results with aliases will be missing from the delegated result. `mergeSchemas` and `transformSchemas` go around that by using `src/stitching/defaultMergedResolver` for all fields without explicit resolver. When building new libraries around delegation, one should consider how the aliases will be handled. diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 281faec8e84..361834ee402 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -164,6 +164,10 @@ mergeSchemas({ [Run the above example on Launchpad.](https://launchpad.graphql.com/8r11mk9jq) +

+ +*** TODO *** +

Complex example

For a more complicated example involving properties and bookings, with implementations of all of the resolvers, check out the Launchpad links below: diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md new file mode 100644 index 00000000000..ce7f7999c37 --- /dev/null +++ b/docs/source/schema-transforms.md @@ -0,0 +1,8 @@ +--- +title: Schema transforms +description: Automatically transforming schemas +--- + + + +

API

From ec96674b053753c022d6a7e7567f1c308df032fe Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Thu, 15 Mar 2018 15:47:40 +0200 Subject: [PATCH 19/57] Updated docs --- docs/source/schema-delegation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md index f0b49b932b2..c4ec11a4575 100644 --- a/docs/source/schema-delegation.md +++ b/docs/source/schema-delegation.md @@ -9,7 +9,7 @@ Schema delegation is a way to automatically forward query or a part of the query * [Schema transforms](./schema-transforms.html) - manipulating with schemas, while keep ability to delegate to them * [Schema stitching](./schema-stitching) - extending schemas and connecting multiple schemas together -Delegation is performed by one function - `delegateToSchema`. It should be called from within a resolver and it uses the GraphQL query part that starts at resolver part to create a query that will be executed on a subschema. +Delegation is performed by one function - `delegateToSchema`. It should be called from within a parent schema resolver. It uses the GraphQL query tree starts at the resolver to create a query that will be executed on a subschema.

Motivational example

From 59e0da583e59443c9552a388426eafdc9ee400ca Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Fri, 16 Mar 2018 17:07:16 +0200 Subject: [PATCH 20/57] More docs --- docs/_config.yml | 2 +- docs/source/schema-stitching.md | 227 ++++++++++++++++++++++++++++-- docs/source/schema-transforms.md | 229 +++++++++++++++++++++++++++++++ 3 files changed, 447 insertions(+), 11 deletions(-) diff --git a/docs/_config.yml b/docs/_config.yml index 8f7a7691b8b..404d1d911ce 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -17,11 +17,11 @@ sidebar_categories: - scalars - mocking - connectors + - schema-directives - schema-delegation - remote-schemas - schema-transforms - schema-stitching - - schema-directives Related: - title: Apollo Server href: https://www.apollographql.com/docs/apollo-server/ diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 361834ee402..a6b136faa02 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -64,7 +64,15 @@ const authorSchema = makeExecutableSchema({ addMockFunctionsToSchema({ schema: authorSchema }); export const schema = mergeSchemas({ - schemas: [chirpSchema, authorSchema], + schemas: [ + { + name: 'Chirp', + schema: chirpSchema, + }, { + name: 'Author', + schema: authorSchema, + } + ], }); ``` @@ -104,7 +112,20 @@ We can now merge these three schemas together: ```js mergeSchemas({ - schemas: [chirpSchema, authorSchema, linkTypeDefs], + schemas: [ + { + name: 'Chirp', + schema: chirpSchema, + }, + { + name: 'Author', + schema: authorSchema, + }, + { + name: 'Links', + schema: linkTypeDefs, + } + ], }); ``` @@ -122,7 +143,20 @@ A complete implementation of schema stitching for these schemas would look like ```js mergeSchemas({ - schemas: [chirpSchema, authorSchema, linkTypeDefs], + schemas: [ + { + name: 'Chirp', + schema: chirpSchema, + }, + { + name: 'Author', + schema: authorSchema, + }, + { + name: 'Links', + schema: linkTypeDefs, + } + ], resolvers: mergeInfo => ({ User: { chirps: { @@ -130,6 +164,7 @@ mergeSchemas({ resolve(parent, args, context, info) { const authorId = parent.id; return mergeInfo.delegate( + 'Chirp', 'query', 'chirpsByAuthorId', { @@ -147,6 +182,7 @@ mergeSchemas({ resolve(parent, args, context, info) { const id = parent.authorId; return mergeInfo.delegate( + 'Product', 'query', 'userById', { @@ -164,9 +200,115 @@ mergeSchemas({ [Run the above example on Launchpad.](https://launchpad.graphql.com/8r11mk9jq) -

+

Using with Transforms

-*** TODO *** +Often, when creating gateways, one might want to modify one of the schemas. The most common tasks include renaming some of the types, filter or removing some of the root fields. By using [transforms](./schema-transforms) with schema stitching, one can do it without much manual work. + +While normally, one delegates directly to the schema that is merged, when schemas are transformed, one often need to delegate to original, untransformed schema. For example, even if some root fields are modified, it's often required to still use those root fields inside of the resolvers, for example for links. + +In this example, we'll namespace `Chirp` schema and remove `chirpsByAuthorId` from it, by using transforms built-in into `graphql-tools`. + +```js +import { + makeExecutableSchema, + addMockFunctionsToSchema, + mergeSchemas, + Transforms, + makeTransformSchema, +} from 'graphql-tools'; + +// Mocked chirp schema; we don't want to worry about the schema +// implementation right now since we're just demonstrating +// schema stitching +const chirpSchema = makeExecutableSchema({ + typeDefs: ` + type Chirp { + id: ID! + text: String + authorId: ID! + } + + type Query { + chirpById(id: ID!): Chirp + chirpsByAuthorId(authorId: ID!): [Chirp] + } + ` +}); + +addMockFunctionsToSchema({ schema: chirpSchema }); + +// create transform schema + +const transformedChirpSchema = makeTransformSchema(propertySchema, [ + Transforms.FilterRootFields((operation: string, rootField: string) => + ['Query.chirpById'].includes(`${operation}.${rootField}`), + ), + Transforms.RenameTypes((name: string) => `Chirp_${name}`), + Transforms.RenameRootFields((name: string) => `Chirp_${name}`), +]); +``` + +Now we have a schema that has all fields and types prefixed with `Chirp_` and has only `chirpById` root field. Now let's implement the resolvers like in previous example. + +```js +mergeSchemas({ + schemas: [ + { + name: 'Chirp', + schema: chirpSchema, + }, + { + name: 'Author', + schema: authorSchema, + }, + { + name: 'Links', + schema: linkTypeDefs, + } + ], + resolvers: mergeInfo => ({ + User: { + chirps: { + fragment: `fragment UserFragment on User { id }`, + resolve(parent, args, context, info) { + const authorId = parent.id; + return mergeInfo.delegateToSchema( + chirpSchema, + 'query', + 'chirpsByAuthorId', + { + authorId, + }, + context, + info, + chirpSchema.transforms, + ); + }, + }, + }, + Chirp_Chirp: { + author: { + fragment: `fragment ChirpFragment on Chirp { authorId }`, + resolve(parent, args, context, info) { + const id = parent.authorId; + return mergeInfo.delegate( + 'Product', + 'query', + 'userById', + { + id, + }, + context, + info, + ); + }, + }, + }, + }), +}); +``` + +We use `delegateToSchema` to be able to delegate to original schema. This way we can use `chirpsByAuthorId` field that has been filtered out of the transformed schema.

Complex example

@@ -182,12 +324,25 @@ For a more complicated example involving properties and bookings, with implement ``` mergeSchemas({ - schemas: Array, + schemas: Array<{ + name: string, + schema: GraphQLSchema | string + }>, resolvers?: (mergeInfo: MergeInfo) => IResolvers, onTypeConflict?: ( left: GraphQLNamedType, - right: GraphQLNamedType - ) => GraphQLNamedType + right: GraphQLNamedType, + info?: { + left: { + name: string; + schema?: GraphQLSchema; + }; + right: { + name: string; + schema?: GraphQLSchema; + }; + }, + ) => GraphQLNamedType; }) ``` @@ -195,7 +350,7 @@ This is the main function that implements schema stitching. Read below for a des #### schemas -`schemas` is an array of either `GraphQLSchema` objects or strings. For strings, only `extend type` declarations will be used. Passing strings is useful to add fields to existing types to link schemas together, as described in the example above. +`schemas` is an array of object with `name` and `schema` that's either `GraphQLSchema` objects or strings. Strings can contain type extensions or GraphQL types, they will be added to resulting schema. Note that type extensions are always applied last, while types are used in order of schemas. #### resolvers @@ -208,6 +363,7 @@ resolvers: mergeInfo => ({ fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { return mergeInfo.delegate( + 'Bookings', // Schema name 'query', 'propertyById', { @@ -228,16 +384,29 @@ resolvers: mergeInfo => ({ ```js type MergeInfo = { + getSubSchema(schemaName: string) => GraphQLSchema, delegate( + schemaName: string, operation: 'query' | 'mutation', rootFieldName: string, args: any, context: any, info: GraphQLResolveInfo - ) => any + ) => any, + delegateToSchema( + schema: GraphQLSchema, + operation: 'query' | 'mutation' | 'subscription', + fieldName: string, + args: { [key: string]: any }, + context: { [key: string]: any }, + info: GraphQLResolveInfo, + transforms?: Array, + ) => any, } ``` +`getSubSchema` lets you retrieve schema passed to `mergeSchemas` by name. + `delegate` takes the operation type (`query` or `mutation`) and root field names, together with the GraphQL execution context and resolve info, as well as arguments for the root field. It delegates to one of the merged schema and makes sure that only relevant fields are requested. @@ -254,9 +423,47 @@ mergeInfo.delegate( ); ``` +`delegateToSchema` allows delegating to any GraphQLSchema, while adding `fragmentReplacement` transforms. It's identical to `delegateToSchema` function otherwise. See [Schema Delegation](./schema-delegation.html) and *Using with transforms* section of this documentation. + + #### onTypeConflict +``` +type OnTypeConflict = ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + name: string; + schema?: GraphQLSchema; + }; + right: { + name: string; + schema?: GraphQLSchema; + }; + }, +) => GraphQLNamedType; +``` + `onTypeConflict` lets you customize type resolving logic. The default logic is to take the first encountered type of all the types with the same name. This method allows customization of this behavior, for example by taking another type or merging types together. + +For example, taking types from last schemas, instead of first. + +```js +const onTypeConflict = (left, right) => right; +``` + +Taking type from the schema that has higher field `version`. + +```js +const onTypeConflict = (left, right, info) => { + if (info.left.schema.version >= info.right.schema.version) { + return left; + } else { + return right; + } +} +``` diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index ce7f7999c37..94c88e90b6f 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -3,6 +3,235 @@ title: Schema transforms description: Automatically transforming schemas --- +Schema transforms are abstractions that let you specify transformations to the schema in a generic way, while keeping an ability to delegate back to original schema. It's useful when working with remote schemas, when building GraphQL gateways and using schema stitching. +While it's possible to change the schema and modify resolvers to accomodate the changes, for many things that would require lots of extra work. Transforms allow definining a generic set of functions that handle for yourself. They can be reused between schemas and applications. + +Transform is a set of at least one of three *transformers* - a schema transformer, a request transformer and result transformer. Schema transformer takes a schema and returns a schema, request transformer modifies GraphQL Document and variables, while result transformer modifies the request. + +While schema delegation can in many cases accomodate to changes in schema, especially the changes that add or remove types or fields to the schema, changes that require mapping new types or fields to old types require additional transforms. For example, let's consider changing the name of the type in a very simple schema. Let's imagine we've defined a function that takes a GraphQLSchema and replaces all instances of type `Test` with `NewTest`. + +```graphql +# old schema +type Test { + id: ID! + name: String +} + +type Query { + returnTest: Test +} + +# new schema + +type NewTest { + id: ID! + name: String +} + +type Query { + returnTest: NewTest +} +``` + +We want `NewTest` to be automatically mappped to old typed name `Test`. From the first glance, it should actually work fine: + +``` +query { + returnTest { + id + name + } +} +``` + +As contents of the types didn't change, this can easily be delegate to old schema without any changes. However things change, when Fragments and variables come into play. + +``` +query { + returnTest } + id + ... on NewTest { + name + } + } +} +``` + +Type `NewTest` doesn't exist on old schema, thus it will be filtered out by delegation. Here a request transform comes into play, where we can define that we want to rename all times we encounter `NewTest` type name to name `Test` in old schema. + +Lastly, we need a result transform. This only comes into play if we request `__typename` from the schema, and here we again need to map from `Test` to `NewTest`.

API

+ +

mergeSchemas

+ +```js +type Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (request: Request) => Request; + transformResult?: (result: Result) => Result; +}; + +type Request = { + document: DocumentNode; + variables: Record; + extensions?: Record; +}; + +type Result = ExecutionResult & { + extensions?: Record; +}; +``` + +

makeTransformSchema

+ +Given a `GraphQLSchema` and an array of `Transform`, produce a new schema transformed by them. Also creates delegating resolvers mapping from new schema root fields to old schema root fields. Most of the time, when using transforms, you would be using this. + +

visitSchema

+ +```js +enum VisitSchemaKind { + TYPE = 'VisitSchemaKind.TYPE', + SCALAR_TYPE = 'VisitSchemaKind.SCALAR_TYPE', + ENUM_TYPE = 'VisitSchemaKind.ENUM_TYPE', + COMPOSITE_TYPE = 'VisitSchemaKind.COMPOSITE_TYPE', + OBJECT_TYPE = 'VisitSchemaKind.OBJECT_TYPE', + INPUT_OBJECT_TYPE = 'VisitSchemaKind.INPUT_OBJECT_TYPE', + ABSTRACT_TYPE = 'VisitSchemaKind.ABSTRACT_TYPE', + UNION_TYPE = 'VisitSchemaKind.UNION_TYPE', + INTERFACE_TYPE = 'VisitSchemaKind.INTERFACE_TYPE', + ROOT_OBJECT = 'VisitSchemaKind.ROOT_OBJECT', + QUERY = 'VisitSchemaKind.QUERY', + MUTATION = 'VisitSchemaKind.MUTATION', + SUBSCRIPTION = 'VisitSchemaKind.SUBSCRIPTION', +} + +type SchemaVisitor = { [key: VisitSchemaKind]: TypeVisitor }; +type TypeVisitor = ( + type: GraphQLType, + schema: GraphQLSchema, +) => GraphQLNamedType; + +function visitSchema( + schema: GraphQLSchema, + visitor: SchemaVisitor, + stripResolvers?: Boolean, +) => GraphQLSchema; +``` + +A helper function to modify schema, modelled after `visit` function in `graphql-js`. It's often convinient to use this (together with `visit/visitWithTypeInfo` and `visitObject`) to create transforms. Accepts visitor, where keys are the kind of types one want to visit. More specific kinds override less specific ones (so `SUBSCRIPTION`, if present, would be called instead of `ROOT_OBJECT` or `INTERFACE_TYPE`). If `stripResolvers` is passed, all field resolvers would be removed from the schema. When writing transforms, you usually don't need to do that. Example that changes name of all types, but root. + +```js +visitSchema(schema, { + [VisitSchemaKind.TYPE]( + type: GraphQLNamedType, + ): GraphQLNamedType | undefined { + const newName = `Foo_${type.name}`; + if (newName && newName !== type.name) { + const newType = Object.assign(Object.create(type), type); + newType.name = newName; + return newType; + } + }, + + [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { + return undefined; + }, +}); +``` + +

visitObject

+ +```js +type ObjectVisitor = ( + key: string, + value: any, + parents: Array, +) => any | undefined | null; + +visitObject(object: any, visitor: ObjectVisitor) => any +``` + +A helper function to do a depth-first traversal of a nested object, such as `data` of GraphQL `ExecutionResult`. If `visitor` returns `undefined`, the branch is kept intact. On `null` branch is removed. Any other results make the branch be replaced with it. Note that `visitObject` will visit a replaced result too. + +

Built-in transforms

+ +Built-in transforms are all functions returning a `Transform`. + +### Modifying types + +* `FilterTypes(filter: (type: GraphQLNamedType) => boolean)` - remove all types for which `filter` returns false +* `RenameTypes(renamer, options?)` - rename types by applying `renamer` to each type name. If it returns `undefined`, name isn't changed. Options controls whether built-in types and scalars are renamed. Root objects aren't renamed by this transform + +```js +RenameTypes( + (name: string) => string | undefined, + options?: { + renameBuiltins: Boolean, + renameScalars: Boolean + } +) +``` + +### Modifying root fields + +* `TransformRootFields(transformer: RootTransformer)` - given a transformer, abritrarily transform root fields. Transformer can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, a null to remove the field or undefined to do nothing. + +```js +TransformRootFields(transformer: RootTransformer) + +type RootTransformer = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | undefined; +``` + +* `FilterRootFields(filter: RootFilter)` - filter out root fields, for which filter returns false. + +```js +FilterRootFields(filter: RootFilter) + +type RootFilter = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => Boolean; + +``` + +* `RenameRootFields(renamer)` - rename root fields, by applying `renamer` to their names. + +```js +function RenameRootFields( + renamer: ( + operation: 'Query' | 'Mutation' | 'Subscription', + name: string, + field: GraphQLField, + ) => string, +) +``` + +### Other + +* `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, mapping: FieldToFragmentMapping)` - replace fields in types defined in mapping by an inline fragment. Used by `mergeSchemas` to resolve `fragment` option. + +```js +type FieldToFragmentMapping = { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +}; +``` + +

delegateToSchema transforms

+ +Those transforms are automatically added to transform list by `delegateToSchema`. Useful if you want to build an alternative `delegateToSchema` implementation. + +* `AddArgumentsAsVariables` - given a schema and arguments to root field passed, make those arguments document variables +* `AddTypenameToAbstract` - add `__typename` to all abstract types in the document +* `FilterToSchema` - given a schema and document, remove all fields, variables and fragments for the types that don't exist in that schema +* `CheckResultAndHandleErrors` - given a result from a subschema, propagate errors so that they match correct subfield. Also provide correct key if the aliases are used. From 51d2ccf4953567267d69f505b752fba18a100254 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Fri, 16 Mar 2018 17:37:36 +0200 Subject: [PATCH 21/57] Removed re-export --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index fc9b765ce02..2a74c8b4c65 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,4 @@ export * from './schemaGenerator'; export * from './mock'; export * from './stitching'; export * from './transforms'; -export * from './schemaVisitor'; +export { SchemaDirectiveVisitor } from './schemaVisitor'; From e38044153e204e1e353fda6f580db64065e7b0ba Mon Sep 17 00:00:00 2001 From: Matthew Zikherman Date: Mon, 26 Mar 2018 16:30:38 -0400 Subject: [PATCH 22/57] Add failing spec for merging schemas w/ unions that implement an interface --- src/test/testMergeSchemas.ts | 30 ++++++++++++++++++++++++++++++ src/test/testingSchemas.ts | 8 ++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 1cd5420cb27..18b8c740be0 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -884,6 +884,36 @@ bookingById(id: "b1") { }); }); + it('unions implementing an interface', async () => { + const mergedResult = await graphql( + mergedSchema, + ` + query { + customerById(id: "c1") { + ... on Person { + name + } + vehicle { + ... on Node { + __typename + id + } + } + } + } + `, + ); + + expect(mergedResult).to.deep.equal({ + data: { + customerById: { + name: 'Exampler Customer', + vehicle: { __typename: 'Bike', id: 'v1' }, + }, + }, + }); + }); + it('input objects with default', async () => { const mergedResult = await graphql( mergedSchema, diff --git a/src/test/testingSchemas.ts b/src/test/testingSchemas.ts index 0d8e3f366ed..8539ca60899 100644 --- a/src/test/testingSchemas.ts +++ b/src/test/testingSchemas.ts @@ -469,16 +469,20 @@ const bookingRootTypeDefs = ` union Vehicle = Bike | Car - type Bike { + type Bike implements Node { id: ID! bikeType: String } - type Car { + type Car implements Node { id: ID! licensePlate: String } + interface Node { + id: ID! + } + type Query { bookingById(id: ID!): Booking bookingsByPropertyId(propertyId: ID!, limit: Int): [Booking!] From 6b7a1ce6feced37dc00f2a4e83ba9b3a08bd624a Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 2 Apr 2018 10:55:16 +0300 Subject: [PATCH 23/57] Expand new interfaces to their implementations --- src/implementsAbstractType.ts | 35 +++++ src/stitching/mergeSchemas.ts | 12 +- src/test/testMergeSchemas.ts | 57 ++++++++ src/test/testingSchemas.ts | 17 ++- src/transforms/ExpandAbstractTypes.ts | 196 ++++++++++++++++++++++++++ src/transforms/FilterToSchema.ts | 44 ++---- src/transforms/index.ts | 2 + 7 files changed, 318 insertions(+), 45 deletions(-) create mode 100644 src/implementsAbstractType.ts create mode 100644 src/transforms/ExpandAbstractTypes.ts diff --git a/src/implementsAbstractType.ts b/src/implementsAbstractType.ts new file mode 100644 index 00000000000..f78d3df0f0f --- /dev/null +++ b/src/implementsAbstractType.ts @@ -0,0 +1,35 @@ +import { + GraphQLType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLUnionType, +} from 'graphql'; + +export default function implementsAbstractType( + parent: GraphQLType, + child: GraphQLType, + bail: boolean = false, +): boolean { + if (parent === child) { + return true; + } else if ( + parent instanceof GraphQLInterfaceType && + child instanceof GraphQLObjectType + ) { + return child.getInterfaces().indexOf(parent) !== -1; + } else if ( + parent instanceof GraphQLInterfaceType && + child instanceof GraphQLInterfaceType + ) { + return true; + } else if ( + parent instanceof GraphQLUnionType && + child instanceof GraphQLObjectType + ) { + return parent.getTypes().indexOf(child) !== -1; + } else if (parent instanceof GraphQLObjectType && !bail) { + return implementsAbstractType(child, parent, true); + } + + return false; +} diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index ba1b937c2db..a5d91dbe918 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -324,6 +324,10 @@ function createMergeInfo( In version 3.0, \`delegate\` requires a schema name as a first argument, have you updated your code?`); } const schema = schemas[schemaName]; + const expandTransforms = Transforms.ExpandAbstractTypes( + info.schema, + schema, + ); const fragmentTransform = Transforms.ReplaceFieldWithFragment( schema, fragmentReplacements, @@ -338,7 +342,7 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo args, context, info, - [fragmentTransform], + [expandTransforms, fragmentTransform], ); }, delegateToSchema( @@ -350,6 +354,10 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo info: GraphQLResolveInfo, transforms?: Array, ) { + const expandTransforms = Transforms.ExpandAbstractTypes( + info.schema, + schema, + ); const fragmentTransform = Transforms.ReplaceFieldWithFragment( schema, fragmentReplacements, @@ -361,7 +369,7 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo args, context, info, - [...(transforms || []), fragmentTransform], + [...(transforms || []), expandTransforms, fragmentTransform], ); }, }; diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index a81eb9b0b39..ce269e467fc 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -138,6 +138,10 @@ let linkSchema = ` id: ID! } + extend type Car implements Node + + extend type Bike implements Node + extend type Booking implements Node { """ The property of the booking. @@ -248,6 +252,10 @@ if (process.env.GRAPHQL_VERSION === '^0.11') { id: ID! } + extend type Car implements Node + + extend type Bike implements Node + extend type Booking implements Node { # The property of the booking. property: Property @@ -921,6 +929,46 @@ bookingById(id: "b1") { }); }); + it('interfaces spread from top level functions', async () => { + const mergedResult = await graphql( + mergedSchema, + ` + query { + first: customerById(id: "c1") { + name + ... on Node { + id + } + } + + second: customerById(id: "c1") { + ...NodeFragment + } + } + + fragment NodeFragment on Node { + id + ... on Customer { + name + } + } + `, + ); + + expect(mergedResult).to.deep.equal({ + data: { + first: { + id: 'c1', + name: 'Exampler Customer', + }, + second: { + id: 'c1', + name: 'Exampler Customer', + }, + }, + }); + }); + it('unions implementing an interface', async () => { const mergedResult = await graphql( mergedSchema, @@ -936,8 +984,16 @@ bookingById(id: "b1") { id } } + secondVehicle: vehicle { + ...NodeFragment + } } } + + fragment NodeFragment on Node { + id + __typename + } `, ); @@ -946,6 +1002,7 @@ bookingById(id: "b1") { customerById: { name: 'Exampler Customer', vehicle: { __typename: 'Bike', id: 'v1' }, + secondVehicle: { __typename: 'Bike', id: 'v1' }, }, }, }); diff --git a/src/test/testingSchemas.ts b/src/test/testingSchemas.ts index 8539ca60899..00d470232f2 100644 --- a/src/test/testingSchemas.ts +++ b/src/test/testingSchemas.ts @@ -469,20 +469,16 @@ const bookingRootTypeDefs = ` union Vehicle = Bike | Car - type Bike implements Node { + type Bike { id: ID! bikeType: String } - type Car implements Node { + type Car { id: ID! licensePlate: String } - interface Node { - id: ID! - } - type Query { bookingById(id: ID!): Booking bookingsByPropertyId(propertyId: ID!, limit: Int): [Booking!] @@ -734,7 +730,14 @@ export async function makeSchemaRemoteFromLink(schema: GraphQLSchema) { // ensure fetcher support exists from the 2.0 api async function makeExecutableSchemaFromFetcher(schema: GraphQLSchema) { const fetcher: Fetcher = ({ query, operationName, variables, context }) => { - return graphql(schema, print(query), null, context, variables, operationName); + return graphql( + schema, + print(query), + null, + context, + variables, + operationName, + ); }; const clientSchema = await introspectSchema(fetcher); diff --git a/src/transforms/ExpandAbstractTypes.ts b/src/transforms/ExpandAbstractTypes.ts new file mode 100644 index 00000000000..ec9dbebf6b9 --- /dev/null +++ b/src/transforms/ExpandAbstractTypes.ts @@ -0,0 +1,196 @@ +import { + DocumentNode, + FragmentDefinitionNode, + GraphQLSchema, + GraphQLCompositeType, + SelectionSetNode, + Kind, + OperationDefinitionNode, + isAbstractType, + visit, + visitWithTypeInfo, + TypeInfo, + SelectionNode, +} from 'graphql'; +import implementsAbstractType from '../implementsAbstractType'; +import { Transform, Request } from '../Interfaces'; + +type TypeMapping = { [key: string]: Array }; + +export default function ExpandAbstractTypes( + transformedSchema: GraphQLSchema, + targetSchema: GraphQLSchema, +): Transform { + const mapping: TypeMapping = extractPossibleTypes( + transformedSchema, + targetSchema, + ); + return { + transformRequest(originalRequest: Request): Request { + const document = expandAbstractTypes( + targetSchema, + mapping, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + }, + }; +} + +function extractPossibleTypes( + transformedSchema: GraphQLSchema, + targetSchema: GraphQLSchema, +) { + const typeMap = transformedSchema.getTypeMap(); + const mapping = {}; + Object.keys(typeMap).forEach(typeName => { + const type = typeMap[typeName]; + if (isAbstractType(type)) { + const targetType = targetSchema.getType(typeName); + if (!isAbstractType(targetType)) { + const implementations = transformedSchema.getPossibleTypes(type); + mapping[typeName] = implementations + .filter(impl => targetSchema.getType(impl.name)) + .map(impl => impl.name); + } + } + }); + return mapping; +} + +function expandAbstractTypes( + targetSchema: GraphQLSchema, + mapping: TypeMapping, + document: DocumentNode, +): DocumentNode { + const operations: Array< + OperationDefinitionNode + > = document.definitions.filter( + def => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; + const fragments: Array = document.definitions.filter( + def => def.kind === Kind.FRAGMENT_DEFINITION, + ) as Array; + + const existingFragmentNames = fragments.map(fragment => fragment.name.value); + let fragmentCounter = 0; + const generateFragmentName = (typeName: string) => { + let fragmentName; + do { + fragmentName = `_${typeName}_Fragment${fragmentCounter}`; + fragmentCounter++; + } while (existingFragmentNames.indexOf(fragmentName) !== -1); + return fragmentName; + }; + + const newFragments: Array = []; + const fragmentReplacements: { + [fragmentName: string]: Array<{ fragmentName: string; typeName: string }>; + } = {}; + + fragments.forEach((fragment: FragmentDefinitionNode) => { + newFragments.push(fragment); + const possibleTypes = mapping[fragment.typeCondition.name.value]; + if (possibleTypes) { + fragmentReplacements[fragment.name.value] = []; + possibleTypes.forEach(possibleTypeName => { + const name = generateFragmentName(possibleTypeName); + existingFragmentNames.push(name); + const newFragment: FragmentDefinitionNode = { + kind: Kind.FRAGMENT_DEFINITION, + name: { + kind: Kind.NAME, + value: name, + }, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: possibleTypeName, + }, + }, + selectionSet: fragment.selectionSet, + }; + newFragments.push(newFragment); + + fragmentReplacements[fragment.name.value].push({ + fragmentName: name, + typeName: possibleTypeName, + }); + }); + } + }); + + const newDocument = { + ...document, + definitions: [...operations, ...newFragments], + }; + const typeInfo = new TypeInfo(targetSchema); + return visit( + newDocument, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET](node: SelectionSetNode) { + const newSelections = [...node.selections]; + const parentType: GraphQLCompositeType = typeInfo.getParentType(); + node.selections.forEach((selection: SelectionNode) => { + if (selection.kind === Kind.INLINE_FRAGMENT) { + const possibleTypes = mapping[selection.typeCondition.name.value]; + if (possibleTypes) { + possibleTypes.forEach(possibleType => { + if ( + implementsAbstractType( + parentType, + targetSchema.getType(possibleType), + ) + ) { + newSelections.push({ + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: possibleType, + }, + }, + selectionSet: selection.selectionSet, + }); + } + }); + } + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = selection.name.value; + const replacements = fragmentReplacements[fragmentName]; + if (replacements) { + replacements.forEach(replacement => { + const typeName = replacement.typeName; + if ( + implementsAbstractType( + parentType, + targetSchema.getType(typeName), + ) + ) { + newSelections.push({ + kind: Kind.FRAGMENT_SPREAD, + name: { + kind: Kind.NAME, + value: replacement.fragmentName, + }, + }); + } + }); + } + } + }); + if (newSelections.length !== node.selections.length) { + return { + ...node, + selections: newSelections, + }; + } + }, + }), + ); +} diff --git a/src/transforms/FilterToSchema.ts b/src/transforms/FilterToSchema.ts index 2eb48f5ed23..afcf4cb0680 100644 --- a/src/transforms/FilterToSchema.ts +++ b/src/transforms/FilterToSchema.ts @@ -22,6 +22,7 @@ import { visit, } from 'graphql'; import { Request } from '../Interfaces'; +import implementsAbstractType from '../implementsAbstractType'; import { Transform } from './transforms'; export default function FilterToSchema(targetSchema: GraphQLSchema): Transform { @@ -57,13 +58,13 @@ function filterDocumentToSchema( const newOperations: Array = []; let newFragments: Array = []; - const validFragments: Array< - FragmentDefinitionNode - > = fragments.filter((fragment: FragmentDefinitionNode) => { - const typeName = fragment.typeCondition.name.value; - const type = targetSchema.getType(typeName); - return Boolean(type); - }); + const validFragments: Array = fragments.filter( + (fragment: FragmentDefinitionNode) => { + const typeName = fragment.typeCondition.name.value; + const type = targetSchema.getType(typeName); + return Boolean(type); + }, + ); const validFragmentsWithType: { [name: string]: GraphQLType } = {}; validFragments.forEach((fragment: FragmentDefinitionNode) => { @@ -260,35 +261,6 @@ function resolveType(type: GraphQLType): GraphQLNamedType { return lastType; } -function implementsAbstractType( - parent: GraphQLType, - child: GraphQLType, - bail: boolean = false, -): boolean { - if (parent === child) { - return true; - } else if ( - parent instanceof GraphQLInterfaceType && - child instanceof GraphQLObjectType - ) { - return child.getInterfaces().indexOf(parent) !== -1; - } else if ( - parent instanceof GraphQLInterfaceType && - child instanceof GraphQLInterfaceType - ) { - return true; - } else if ( - parent instanceof GraphQLUnionType && - child instanceof GraphQLObjectType - ) { - return parent.getTypes().indexOf(child) !== -1; - } else if (parent instanceof GraphQLObjectType && !bail) { - return implementsAbstractType(child, parent, true); - } - - return false; -} - function union(...arrays: Array>): Array { const cache: { [key: string]: Boolean } = {}; const result: Array = []; diff --git a/src/transforms/index.ts b/src/transforms/index.ts index 0b26e040b91..b138605e487 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -9,6 +9,7 @@ import FilterTypes from './FilterTypes'; import TransformRootFields from './TransformRootFields'; import RenameRootFields from './RenameRootFields'; import FilterRootFields from './FilterRootFields'; +import ExpandAbstractTypes from './ExpandAbstractTypes'; export * from './transforms'; export * from './visitSchema'; export { makeTransformSchema }; @@ -23,4 +24,5 @@ export const Transforms: { [name: string]: any } = { TransformRootFields, RenameRootFields, FilterRootFields, + ExpandAbstractTypes, }; From 27bd1d8449062591c9aa95f55ba8a1af484321ed Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 2 Apr 2018 10:56:54 +0300 Subject: [PATCH 24/57] Alpha 11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c93b9542783..f72ae6bb3f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.10", + "version": "3.0.0-alpha.11", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From ed681c9006ce5263a5a3ce4835957f2682299895 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 2 Apr 2018 11:11:57 +0300 Subject: [PATCH 25/57] Older graphql support --- src/test/testMergeSchemas.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index ce269e467fc..6908d6e05b7 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -252,9 +252,13 @@ if (process.env.GRAPHQL_VERSION === '^0.11') { id: ID! } - extend type Car implements Node + extend type Car implements Node { + fakeFieldToSatisfyOldGraphQL: String + } - extend type Bike implements Node + extend type Bike implements Node { + fakeFieldToSatisfyOldGraphQL: String + } extend type Booking implements Node { # The property of the booking. From 07d2f72e2a6b6ab71e5169ea4408cd2008530017 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 2 Apr 2018 11:16:21 +0300 Subject: [PATCH 26/57] Fix older graphql v2 --- src/test/testMergeSchemas.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 6908d6e05b7..71fdb9df762 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -138,9 +138,13 @@ let linkSchema = ` id: ID! } - extend type Car implements Node + extend type Car implements Node { + fakeFieldToSatisfyOldGraphQLRemoveAfter12: String + } - extend type Bike implements Node + extend type Bike implements Node { + fakeFieldToSatisfyOldGraphQLRemoveAfter12: String + } extend type Booking implements Node { """ From a0e5e2ca91219739a48be959403502edc168c354 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 2 Apr 2018 12:10:29 +0300 Subject: [PATCH 27/57] Add __typename at expansion --- src/test/testMergeSchemas.ts | 77 +++++++++++++-------------- src/transforms/ExpandAbstractTypes.ts | 8 +++ 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 71fdb9df762..962fe1336c9 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -2364,45 +2364,44 @@ fragment BookingFragment on Booking { }); }); - // KNOWN BUG - // it('fragments on interfaces in merged schema', async () => { - // const result = await graphql( - // mergedSchema, - // ` - // query($bid: ID!) { - // node(id: $bid) { - // ...NodeFragment - // } - // } - // - // fragment NodeFragment on Node { - // id - // ... on Property { - // name - // } - // ... on Booking { - // startTime - // endTime - // } - // } - // `, - // {}, - // {}, - // { - // bid: 'b1', - // }, - // ); - // - // expect(result).to.deep.equal({ - // data: { - // node: { - // id: 'b1', - // startTime: '2016-05-04', - // endTime: '2016-06-03', - // }, - // }, - // }); - // }); + it('fragments on interfaces in merged schema', async () => { + const result = await graphql( + mergedSchema, + ` + query($bid: ID!) { + node(id: $bid) { + ...NodeFragment + } + } + + fragment NodeFragment on Node { + id + ... on Property { + name + } + ... on Booking { + startTime + endTime + } + } + `, + {}, + {}, + { + bid: 'b1', + }, + ); + + expect(result).to.deep.equal({ + data: { + node: { + id: 'b1', + startTime: '2016-05-04', + endTime: '2016-06-03', + }, + }, + }); + }); it('multi-interface filter', async () => { const result = await graphql( diff --git a/src/transforms/ExpandAbstractTypes.ts b/src/transforms/ExpandAbstractTypes.ts index ec9dbebf6b9..48f1b777c2a 100644 --- a/src/transforms/ExpandAbstractTypes.ts +++ b/src/transforms/ExpandAbstractTypes.ts @@ -185,6 +185,14 @@ function expandAbstractTypes( } }); if (newSelections.length !== node.selections.length) { + // often this happening means we need typename anyway + newSelections.push({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); return { ...node, selections: newSelections, From 388973a74248debabde03b280d2dec8548d6fdc2 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 2 Apr 2018 12:10:34 +0300 Subject: [PATCH 28/57] Alpha 12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f72ae6bb3f0..205374faaaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.11", + "version": "3.0.0-alpha.12", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From 04b659eefb96777f1b5a3c0db7b396b5092a4abf Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 3 Apr 2018 10:43:08 +0300 Subject: [PATCH 29/57] Fix more issues with abstract types --- src/implementsAbstractType.ts | 39 ++++++------------ src/test/testMergeSchemas.ts | 58 +++++++++++++++++++++------ src/test/testingSchemas.ts | 31 ++++++++++++++ src/transforms/ExpandAbstractTypes.ts | 41 +++++++++++++++---- src/transforms/FilterToSchema.ts | 5 ++- 5 files changed, 124 insertions(+), 50 deletions(-) diff --git a/src/implementsAbstractType.ts b/src/implementsAbstractType.ts index f78d3df0f0f..b54b51db073 100644 --- a/src/implementsAbstractType.ts +++ b/src/implementsAbstractType.ts @@ -1,35 +1,20 @@ import { GraphQLType, - GraphQLInterfaceType, - GraphQLObjectType, - GraphQLUnionType, + GraphQLSchema, + doTypesOverlap, + isCompositeType, } from 'graphql'; export default function implementsAbstractType( - parent: GraphQLType, - child: GraphQLType, - bail: boolean = false, -): boolean { - if (parent === child) { + schema: GraphQLSchema, + typeA: GraphQLType, + typeB: GraphQLType, +) { + if (typeA === typeB) { return true; - } else if ( - parent instanceof GraphQLInterfaceType && - child instanceof GraphQLObjectType - ) { - return child.getInterfaces().indexOf(parent) !== -1; - } else if ( - parent instanceof GraphQLInterfaceType && - child instanceof GraphQLInterfaceType - ) { - return true; - } else if ( - parent instanceof GraphQLUnionType && - child instanceof GraphQLObjectType - ) { - return parent.getTypes().indexOf(child) !== -1; - } else if (parent instanceof GraphQLObjectType && !bail) { - return implementsAbstractType(child, parent, true); + } else if (isCompositeType(typeA) && isCompositeType(typeB)) { + return doTypesOverlap(schema, typeA, typeB); + } else { + return false; } - - return false; } diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 962fe1336c9..6e09031bdae 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -937,6 +937,51 @@ bookingById(id: "b1") { }); }); + it('unions implementing interface', async () => { + const query = ` + query { + test1: unionTest(output: "Interface") { + ... on TestInterface { + kind + testString + } + ... on TestImpl1 { + foo + } + ... on UnionImpl { + someField + } + } + + test2: unionTest(output: "OtherStuff") { + ... on TestInterface { + kind + testString + } + ... on TestImpl1 { + foo + } + ... on UnionImpl { + someField + } + } + } + `; + const mergedResult = await graphql(mergedSchema, query); + expect(mergedResult).to.deep.equal({ + data: { + test1: { + kind: 'ONE', + testString: 'test', + foo: 'foo', + }, + test2: { + someField: 'Bar', + }, + }, + }); + }); + it('interfaces spread from top level functions', async () => { const mergedResult = await graphql( mergedSchema, @@ -2229,7 +2274,6 @@ fragment BookingFragment on Booking { }); }); - // FIXME: __typename should be automatic describe('merge info defined interfaces', () => { it('inline fragments on existing types in subschema', async () => { const result = await graphql( @@ -2237,14 +2281,12 @@ fragment BookingFragment on Booking { ` query($pid: ID!, $bid: ID!) { property: node(id: $pid) { - __typename id ... on Property { name } } booking: node(id: $bid) { - __typename id ... on Booking { startTime @@ -2264,12 +2306,10 @@ fragment BookingFragment on Booking { expect(result).to.deep.equal({ data: { property: { - __typename: 'Property', id: 'p1', name: 'Super great hotel', }, booking: { - __typename: 'Booking', id: 'b1', startTime: '2016-05-04', endTime: '2016-06-03', @@ -2442,7 +2482,6 @@ fragment BookingFragment on Booking { ` query { nodes { - __typename id ... on Property { name @@ -2463,40 +2502,33 @@ fragment BookingFragment on Booking { id: 'b1', startTime: '2016-05-04', endTime: '2016-06-03', - __typename: 'Booking', }, { id: 'b2', startTime: '2016-06-04', endTime: '2016-07-03', - __typename: 'Booking', }, { id: 'b3', startTime: '2016-08-04', endTime: '2016-09-03', - __typename: 'Booking', }, { id: 'b4', startTime: '2016-10-04', endTime: '2016-10-03', - __typename: 'Booking', }, { id: 'p1', name: 'Super great hotel', - __typename: 'Property', }, { id: 'p2', name: 'Another great hotel', - __typename: 'Property', }, { id: 'p3', name: 'BedBugs - The Affordable Hostel', - __typename: 'Property', }, ], }, diff --git a/src/test/testingSchemas.ts b/src/test/testingSchemas.ts index 00d470232f2..2f4b260f19e 100644 --- a/src/test/testingSchemas.ts +++ b/src/test/testingSchemas.ts @@ -267,6 +267,12 @@ const propertyRootTypeDefs = ` bar: String } + type UnionImpl { + someField: String + } + + union TestUnion = TestImpl1 | UnionImpl + input InputWithDefault { test: String = "Foo" } @@ -278,6 +284,7 @@ const propertyRootTypeDefs = ` dateTimeTest: DateTime jsonTest(input: JSON): JSON interfaceTest(kind: TestInterfaceKind): TestInterface + unionTest(output: String): TestUnion errorTest: String errorTestNonNull: String! relay: Query! @@ -337,6 +344,20 @@ const propertyResolvers: IResolvers = { } }, + unionTest(root, { output }) { + if (output === 'Interface') { + return { + kind: 'ONE', + testString: 'test', + foo: 'foo', + }; + } else { + return { + someField: 'Bar', + }; + } + }, + errorTest() { throw new Error('Sample error!'); }, @@ -362,6 +383,16 @@ const propertyResolvers: IResolvers = { }, }, + TestUnion: { + __resolveType(obj) { + if (obj.kind === 'ONE') { + return 'TestImpl1'; + } else { + return 'UnionImpl'; + } + }, + }, + Property: { error() { throw new Error('Property.error error'); diff --git a/src/transforms/ExpandAbstractTypes.ts b/src/transforms/ExpandAbstractTypes.ts index 48f1b777c2a..fec338fc9c9 100644 --- a/src/transforms/ExpandAbstractTypes.ts +++ b/src/transforms/ExpandAbstractTypes.ts @@ -1,16 +1,17 @@ import { DocumentNode, FragmentDefinitionNode, + GraphQLNamedType, GraphQLSchema, - GraphQLCompositeType, - SelectionSetNode, Kind, OperationDefinitionNode, + SelectionNode, + SelectionSetNode, + TypeInfo, + getNamedType, isAbstractType, visit, visitWithTypeInfo, - TypeInfo, - SelectionNode, } from 'graphql'; import implementsAbstractType from '../implementsAbstractType'; import { Transform, Request } from '../Interfaces'; @@ -25,11 +26,13 @@ export default function ExpandAbstractTypes( transformedSchema, targetSchema, ); + const reverseMapping: TypeMapping = flipMapping(mapping); return { transformRequest(originalRequest: Request): Request { const document = expandAbstractTypes( targetSchema, mapping, + reverseMapping, originalRequest.document, ); return { @@ -45,7 +48,7 @@ function extractPossibleTypes( targetSchema: GraphQLSchema, ) { const typeMap = transformedSchema.getTypeMap(); - const mapping = {}; + const mapping: TypeMapping = {}; Object.keys(typeMap).forEach(typeName => { const type = typeMap[typeName]; if (isAbstractType(type)) { @@ -61,9 +64,24 @@ function extractPossibleTypes( return mapping; } +function flipMapping(mapping: TypeMapping): TypeMapping { + const result: TypeMapping = {}; + Object.keys(mapping).forEach(typeName => { + const toTypeNames = mapping[typeName]; + toTypeNames.forEach(toTypeName => { + if (!result[toTypeName]) { + result[toTypeName] = []; + } + result[toTypeName].push(typeName); + }); + }); + return result; +} + function expandAbstractTypes( targetSchema: GraphQLSchema, mapping: TypeMapping, + reverseMapping: TypeMapping, document: DocumentNode, ): DocumentNode { const operations: Array< @@ -134,7 +152,9 @@ function expandAbstractTypes( visitWithTypeInfo(typeInfo, { [Kind.SELECTION_SET](node: SelectionSetNode) { const newSelections = [...node.selections]; - const parentType: GraphQLCompositeType = typeInfo.getParentType(); + const parentType: GraphQLNamedType = getNamedType( + typeInfo.getParentType(), + ); node.selections.forEach((selection: SelectionNode) => { if (selection.kind === Kind.INLINE_FRAGMENT) { const possibleTypes = mapping[selection.typeCondition.name.value]; @@ -142,6 +162,7 @@ function expandAbstractTypes( possibleTypes.forEach(possibleType => { if ( implementsAbstractType( + targetSchema, parentType, targetSchema.getType(possibleType), ) @@ -168,6 +189,7 @@ function expandAbstractTypes( const typeName = replacement.typeName; if ( implementsAbstractType( + targetSchema, parentType, targetSchema.getType(typeName), ) @@ -184,8 +206,8 @@ function expandAbstractTypes( } } }); - if (newSelections.length !== node.selections.length) { - // often this happening means we need typename anyway + + if (parentType && reverseMapping[parentType.name]) { newSelections.push({ kind: Kind.FIELD, name: { @@ -193,6 +215,9 @@ function expandAbstractTypes( value: '__typename', }, }); + } + + if (newSelections.length !== node.selections.length) { return { ...node, selections: newSelections, diff --git a/src/transforms/FilterToSchema.ts b/src/transforms/FilterToSchema.ts index afcf4cb0680..ef1ef2c2a87 100644 --- a/src/transforms/FilterToSchema.ts +++ b/src/transforms/FilterToSchema.ts @@ -160,6 +160,7 @@ function filterSelectionSet( const usedVariables: Array = []; const typeStack: Array = [type]; + // Should be rewritten using visitWithSchema const filteredSelectionSet = visit(selectionSet, { [Kind.FIELD]: { enter(node: FieldNode): null | undefined | FieldNode { @@ -210,7 +211,7 @@ function filterSelectionSet( typeStack[typeStack.length - 1], ); const innerType = validFragments[node.name.value]; - if (!implementsAbstractType(parentType, innerType)) { + if (!implementsAbstractType(schema, parentType, innerType)) { return null; } else { usedFragments.push(node.name.value); @@ -227,7 +228,7 @@ function filterSelectionSet( const parentType: GraphQLNamedType = resolveType( typeStack[typeStack.length - 1], ); - if (implementsAbstractType(parentType, innerType)) { + if (implementsAbstractType(schema, parentType, innerType)) { typeStack.push(innerType); } else { return null; From 4bffa18b2d9b88b4544773cef74af67724786e82 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 3 Apr 2018 10:43:21 +0300 Subject: [PATCH 30/57] Alpha 13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 205374faaaf..78369719ed8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From a19d94ab98b8c19dde634f6efe15fc3f9b23b0bc Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 3 Apr 2018 13:33:56 +0300 Subject: [PATCH 31/57] Rollback to old API --- docs/source/schema-stitching.md | 127 +++--------- src/Interfaces.ts | 16 +- src/stitching/mergeSchemas.ts | 158 ++++++++------- src/test/testAlternateMergeSchemas.ts | 15 +- src/test/testMergeSchemas.ts | 276 +++++--------------------- 5 files changed, 183 insertions(+), 409 deletions(-) diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index a6b136faa02..b801709b067 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -65,13 +65,8 @@ addMockFunctionsToSchema({ schema: authorSchema }); export const schema = mergeSchemas({ schemas: [ - { - name: 'Chirp', - schema: chirpSchema, - }, { - name: 'Author', - schema: authorSchema, - } + chirpSchema, + authorSchema, ], }); ``` @@ -113,18 +108,9 @@ We can now merge these three schemas together: ```js mergeSchemas({ schemas: [ - { - name: 'Chirp', - schema: chirpSchema, - }, - { - name: 'Author', - schema: authorSchema, - }, - { - name: 'Links', - schema: linkTypeDefs, - } + chirpSchema, + authorSchema, + linkTypeDefs, ], }); ``` @@ -135,7 +121,7 @@ So what should these resolvers look like? When we resolve `User.chirps` or `Chirp.author`, we want to delegate to the relevant root fields. To get from a user to its chirps for example, we'll want to use the `id` of the user to call `chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call into `userById`. -Resolvers specified as part of `mergeSchema` have access to a `delegate` function that allows you to delegate to root fields. +Resolvers specified as part of `mergeSchema` have access to a `delegateToSchema` function that allows you to delegate to subschemas. In order to delegate to these root fields, we'll need to make sure we've actually requested the `id` of the user or the `authorId` of the chirp. To avoid forcing users to add these to their queries manually, resolvers on a merged schema can define a fragment that specifies the required fields, and these will be added to the query automatically. @@ -144,27 +130,19 @@ A complete implementation of schema stitching for these schemas would look like ```js mergeSchemas({ schemas: [ - { - name: 'Chirp', - schema: chirpSchema, - }, - { - name: 'Author', - schema: authorSchema, - }, - { - name: 'Links', - schema: linkTypeDefs, - } + chirpSchema, + authorSchema, + linkTypeDefs, ], - resolvers: mergeInfo => ({ + ], + resolvers: { User: { chirps: { fragment: `fragment UserFragment on User { id }`, resolve(parent, args, context, info) { const authorId = parent.id; - return mergeInfo.delegate( - 'Chirp', + return info.mergeInfo.delegateToSchema( + chirpSchema, 'query', 'chirpsByAuthorId', { @@ -181,8 +159,8 @@ mergeSchemas({ fragment: `fragment ChirpFragment on Chirp { authorId }`, resolve(parent, args, context, info) { const id = parent.authorId; - return mergeInfo.delegate( - 'Product', + return info.mergeInfo.delegateToSchema( + productSchema, 'query', 'userById', { @@ -194,7 +172,7 @@ mergeSchemas({ }, }, }, - }), + }, }); ``` @@ -253,18 +231,9 @@ Now we have a schema that has all fields and types prefixed with `Chirp_` and ha ```js mergeSchemas({ schemas: [ - { - name: 'Chirp', - schema: chirpSchema, - }, - { - name: 'Author', - schema: authorSchema, - }, - { - name: 'Links', - schema: linkTypeDefs, - } + transformedChirpSchema, + authorSchema, + linkTypeDefs, ], resolvers: mergeInfo => ({ User: { @@ -281,7 +250,7 @@ mergeSchemas({ }, context, info, - chirpSchema.transforms, + transformedChirpSchema.transforms, ); }, }, @@ -291,8 +260,8 @@ mergeSchemas({ fragment: `fragment ChirpFragment on Chirp { authorId }`, resolve(parent, args, context, info) { const id = parent.authorId; - return mergeInfo.delegate( - 'Product', + return mergeInfo.delegateToSchema( + authorSchema, 'query', 'userById', { @@ -324,21 +293,16 @@ For a more complicated example involving properties and bookings, with implement ``` mergeSchemas({ - schemas: Array<{ - name: string, - schema: GraphQLSchema | string - }>, - resolvers?: (mergeInfo: MergeInfo) => IResolvers, + schemas: Array>; + resolvers?: Array | IResolvers; onTypeConflict?: ( left: GraphQLNamedType, right: GraphQLNamedType, info?: { left: { - name: string; schema?: GraphQLSchema; }; right: { - name: string; schema?: GraphQLSchema; }; }, @@ -350,11 +314,11 @@ This is the main function that implements schema stitching. Read below for a des #### schemas -`schemas` is an array of object with `name` and `schema` that's either `GraphQLSchema` objects or strings. Strings can contain type extensions or GraphQL types, they will be added to resulting schema. Note that type extensions are always applied last, while types are used in order of schemas. +`schemas` is an array of schemas. Schemas can be `GraphQLSchema` objects, strings or list of GraphQL types. Strings can contain type extensions or GraphQL types, they will be added to resulting schema. Note that type extensions are always applied last, while types are used in order of schemas. #### resolvers -`resolvers` is an optional function that takes one argument - `mergeInfo` - and returns resolvers in the same format as [makeExecutableSchema](./resolvers.html). One addition to the resolver format is the possibility to specify a `fragment` for a resolver. `fragment` must be a GraphQL fragment definition, and allows you to specify which fields from the parent schema are required for the resolver to function correctly. +`resolvers` accepts resolvers in same format as [makeExecutableSchema](./resolvers.html). It can also take an Array of resolvers. One addition to the resolver format is the possibility to specify a `fragment` for a resolver. `fragment` must be a GraphQL fragment definition, and allows you to specify which fields from the parent schema are required for the resolver to function correctly. ```js resolvers: mergeInfo => ({ @@ -362,8 +326,8 @@ resolvers: mergeInfo => ({ property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return mergeInfo.delegate( - 'Bookings', // Schema name + return mergeInfo.delegateToSchema( + bookingSchema, 'query', 'propertyById', { @@ -380,19 +344,10 @@ resolvers: mergeInfo => ({ #### mergeInfo and delegate -`mergeInfo` currently is an object with one property - `delegate`. It looks like this: +`mergeInfo` currently is an object with `delegateToSchema` property. It looks like this: ```js type MergeInfo = { - getSubSchema(schemaName: string) => GraphQLSchema, - delegate( - schemaName: string, - operation: 'query' | 'mutation', - rootFieldName: string, - args: any, - context: any, - info: GraphQLResolveInfo - ) => any, delegateToSchema( schema: GraphQLSchema, operation: 'query' | 'mutation' | 'subscription', @@ -404,25 +359,6 @@ type MergeInfo = { ) => any, } ``` - -`getSubSchema` lets you retrieve schema passed to `mergeSchemas` by name. - -`delegate` takes the operation type (`query` or `mutation`) and root field names, together with the GraphQL execution context -and resolve info, as well as arguments for the root field. It delegates to -one of the merged schema and makes sure that only relevant fields are requested. - -```js -mergeInfo.delegate( - 'query', - 'propertyById', - { - id: parent.id, - }, - context, - info, -); -``` - `delegateToSchema` allows delegating to any GraphQLSchema, while adding `fragmentReplacement` transforms. It's identical to `delegateToSchema` function otherwise. See [Schema Delegation](./schema-delegation.html) and *Using with transforms* section of this documentation. @@ -434,11 +370,9 @@ type OnTypeConflict = ( right: GraphQLNamedType, info?: { left: { - name: string; schema?: GraphQLSchema; }; right: { - name: string; schema?: GraphQLSchema; }; }, @@ -447,8 +381,7 @@ type OnTypeConflict = ( `onTypeConflict` lets you customize type resolving logic. The default logic is to take the first encountered type of all the types with the same name. This -method allows customization of this behavior, for example by taking another type or -merging types together. +method allows customization of this behavior, for example by taking another type or merging types together. For example, taking types from last schemas, instead of first. diff --git a/src/Interfaces.ts b/src/Interfaces.ts index aee3ceebc80..c7910f7927c 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -12,9 +12,7 @@ import { DocumentNode, } from 'graphql'; -import { - SchemaDirectiveVisitor, -} from './schemaVisitor'; +import { SchemaDirectiveVisitor } from './schemaVisitor'; /* TODO: Add documentation */ @@ -41,14 +39,13 @@ export type Transform = { export type MergeInfo = { delegate: ( - schemaName: string, type: 'query' | 'mutation' | 'subscription', fieldName: string, args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, + transforms?: Array, ) => any; - getSubSchema: (schemaName: string) => GraphQLSchema; delegateToSchema: ( schema: GraphQLSchema, type: 'query' | 'mutation' | 'subscription', @@ -88,9 +85,13 @@ export interface IConnectorCls { new (context?: TContext): any; } export type IConnectorFn = (context?: TContext) => any; -export type IConnector = IConnectorCls | IConnectorFn; +export type IConnector = + | IConnectorCls + | IConnectorFn; -export type IConnectors = { [key: string]: IConnector }; +export type IConnectors = { + [key: string]: IConnector; +}; export interface IExecutableSchemaDefinition { typeDefs: ITypeDefinitions; @@ -146,7 +147,6 @@ export interface IMockServer { } export type MergeTypeCandidate = { - schemaName: string; schema?: GraphQLSchema; type: GraphQLNamedType; }; diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index a5d91dbe918..29f96cf0bab 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -44,11 +44,9 @@ export type OnTypeConflict = ( right: GraphQLNamedType, info?: { left: { - name: string; schema?: GraphQLSchema; }; right: { - name: string; schema?: GraphQLSchema; }; }, @@ -59,21 +57,13 @@ export default function mergeSchemas({ onTypeConflict, resolvers, }: { - schemas: Array<{ - name: string; - schema: string | GraphQLSchema | Array; - }>; + schemas: Array>; onTypeConflict?: OnTypeConflict; - resolvers?: Array | IResolvers; + resolvers?: + | Array IResolvers)> + | IResolvers + | ((mergeInfo: MergeInfo) => IResolvers); }): GraphQLSchema { - if (schemas.some(schema => !(schema.name && schema.schema))) { - throw new Error( - `Invalid argument \`schemas\`. Expected array of objects of format \`{ name: string, schema: string | schema }\`. - -Argument expected value have been changed in version 3.0, have you updated your code?`, - ); - } - let visitType: VisitType = defaultVisitType; if (onTypeConflict) { console.warn( @@ -84,30 +74,24 @@ Argument expected value have been changed in version 3.0, have you updated your return mergeSchemasImplementation({ schemas, visitType, resolvers }); } -export function mergeSchemasImplementation({ +function mergeSchemasImplementation({ schemas, visitType, resolvers, }: { - schemas: Array<{ - name: string; - schema: string | GraphQLSchema | Array; - }>; + schemas: Array>; visitType?: VisitType; - resolvers?: Array | IResolvers; + resolvers?: + | Array IResolvers)> + | IResolvers + | ((mergeInfo: MergeInfo) => IResolvers); }): GraphQLSchema { - const allSchemas: { [name: string]: GraphQLSchema } = {}; + const allSchemas: Array = []; const typeCandidates: { [name: string]: Array } = {}; const types: { [name: string]: GraphQLNamedType } = {}; const extensions: Array = []; const fragments = {}; - if (!resolvers) { - resolvers = {}; - } else if (Array.isArray(resolvers)) { - resolvers = resolvers.reduce(mergeDeep, {}); - } - if (!visitType) { visitType = defaultVisitType; } @@ -138,30 +122,26 @@ export function mergeSchemasImplementation({ }); }; - schemas.forEach(subSchema => { - if (subSchema.schema instanceof GraphQLSchema) { - const schema = subSchema.schema; - allSchemas[subSchema.name] = schema; + schemas.forEach(schema => { + if (schema instanceof GraphQLSchema) { + allSchemas.push(schema); const queryType = schema.getQueryType(); const mutationType = schema.getMutationType(); const subscriptionType = schema.getSubscriptionType(); if (queryType) { addTypeCandidate(typeCandidates, 'Query', { - schemaName: subSchema.name, schema, type: queryType, }); } if (mutationType) { addTypeCandidate(typeCandidates, 'Mutation', { - schemaName: subSchema.name, schema, type: mutationType, }); } if (subscriptionType) { addTypeCandidate(typeCandidates, 'Subscription', { - schemaName: subSchema.name, schema, type: subscriptionType, }); @@ -178,19 +158,17 @@ export function mergeSchemasImplementation({ type !== subscriptionType ) { addTypeCandidate(typeCandidates, type.name, { - schemaName: subSchema.name, schema, type: type, }); } }); - } else if (typeof subSchema.schema === 'string') { - let parsedSchemaDocument = parse(subSchema.schema); + } else if (typeof schema === 'string') { + let parsedSchemaDocument = parse(schema); parsedSchemaDocument.definitions.forEach(def => { const type = typeFromAST(def, createNamedStub); if (type) { addTypeCandidate(typeCandidates, type.name, { - schemaName: subSchema.name, type: type, }); } @@ -202,18 +180,38 @@ export function mergeSchemasImplementation({ if (extensionsDocument.definitions.length > 0) { extensions.push(extensionsDocument); } - } else if (Array.isArray(subSchema.schema)) { - subSchema.schema.forEach(type => { + } else if (Array.isArray(schema)) { + schema.forEach(type => { addTypeCandidate(typeCandidates, type.name, { - schemaName: subSchema.name, type: type, }); }); } else { - throw new Error(`Invalid schema ${subSchema.name}`); + throw new Error(`Invalid schema passed`); } }); + const mergeInfo = createMergeInfo(allSchemas, fragments); + + if (!resolvers) { + resolvers = {}; + } else if (typeof resolvers === 'function') { + console.warn( + 'Passing functions as resolver parameter is deprecated. Use `info.mergeInfo` instead.', + ); + resolvers = resolvers(mergeInfo); + } else if (Array.isArray(resolvers)) { + resolvers = resolvers.reduce((left, right) => { + if (typeof right === 'function') { + console.warn( + 'Passing functions as resolver parameter is deprecated. Use `info.mergeInfo` instead.', + ); + right = right(mergeInfo); + } + return mergeDeep(left, right); + }, {}); + } + let generatedResolvers = {}; Object.keys(typeCandidates).forEach(typeName => { @@ -254,6 +252,12 @@ export function mergeSchemasImplementation({ }); }); + if (!resolvers) { + resolvers = {}; + } else if (Array.isArray(resolvers)) { + resolvers = resolvers.reduce(mergeDeep, {}); + } + Object.keys(resolvers).forEach(typeName => { const type = resolvers[typeName]; if (type instanceof GraphQLScalarType) { @@ -275,7 +279,6 @@ export function mergeSchemasImplementation({ mergeDeep(generatedResolvers, resolvers), ); - const mergeInfo = createMergeInfo(allSchemas, fragments); forEachField(mergedSchema, field => { if (field.resolve) { const fieldResolver = field.resolve; @@ -297,33 +300,25 @@ export function mergeSchemasImplementation({ } function createMergeInfo( - schemas: { [name: string]: GraphQLSchema }, + allSchemas: Array, fragmentReplacements: { [name: string]: { [fieldName: string]: InlineFragmentNode }; }, ): MergeInfo { return { - getSubSchema(schemaName: string): GraphQLSchema { - const schema = schemas[schemaName]; - if (!schema) { - throw new Error(`No subschema named ${schemaName}.`); - } - return schema; - }, delegate( - schemaName: string, operation: 'query' | 'mutation' | 'subscription', fieldName: string, args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, - ): any { - if (!info) { - throw new Error(`Argument \`info\` is missing. - -In version 3.0, \`delegate\` requires a schema name as a first argument, have you updated your code?`); - } - const schema = schemas[schemaName]; + transforms?: Array, + ) { + console.warn( + '`mergeInfo.delegate` is deprecated. ' + + 'Use `mergeInfo.delegateToSchema and pass explicit schema instances.', + ); + const schema = guessSchemaByRootField(allSchemas, operation, fieldName); const expandTransforms = Transforms.ExpandAbstractTypes( info.schema, schema, @@ -332,9 +327,6 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo schema, fragmentReplacements, ); - if (!schema) { - throw new Error(`No subschema named ${schemaName}.`); - } return delegateToSchema( schema, operation, @@ -342,7 +334,7 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo args, context, info, - [expandTransforms, fragmentTransform], + [...(transforms || []), expandTransforms, fragmentTransform], ); }, delegateToSchema( @@ -375,14 +367,40 @@ In version 3.0, \`delegate\` requires a schema name as a first argument, have yo }; } +function guessSchemaByRootField( + schemas: Array, + operation: 'query' | 'mutation' | 'subscription', + fieldName: string, +): GraphQLSchema { + for (const schema of schemas) { + let rootObject: GraphQLObjectType; + if (operation === 'subscription') { + rootObject = schema.getSubscriptionType(); + } else if (operation === 'mutation') { + rootObject = schema.getMutationType(); + } else { + rootObject = schema.getQueryType(); + } + if (rootObject) { + const fields = rootObject.getFields(); + if (fields[fieldName]) { + return schema; + } + } + } + throw new Error( + `Could not find subschema with field \`{operation}.{fieldName}\``, + ); +} + function createDelegatingResolver( - schemaName: string, + schema: GraphQLSchema, operation: 'query' | 'mutation' | 'subscription', fieldName: string, ): IFieldResolver { return (root, args, context, info) => { - return info.mergeInfo.delegate( - schemaName, + return info.mergeInfo.delegateToSchema( + schema, operation, fieldName, args, @@ -451,11 +469,9 @@ function createVisitTypeFromOnTypeConflict( cands.reduce((prev, next) => { const type = onTypeConflict(prev.type, next.type, { left: { - name: prev.schemaName, schema: prev.schema, }, right: { - name: prev.schemaName, schema: prev.schema, }, }); @@ -503,13 +519,13 @@ const defaultVisitType = ( const resolvers = {}; const resolverKey = operationName === 'subscription' ? 'subscribe' : 'resolve'; - candidates.forEach(({ type: candidateType, schemaName }) => { + candidates.forEach(({ type: candidateType, schema }) => { const candidateFields = (candidateType as GraphQLObjectType).getFields(); fields = { ...fields, ...candidateFields }; Object.keys(candidateFields).forEach(fieldName => { resolvers[fieldName] = { [resolverKey]: createDelegatingResolver( - schemaName, + schema, operationName, fieldName, ), diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index f3d5a906afd..e7973f2e927 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -74,18 +74,9 @@ describe('merge schemas through transforms', () => { mergedSchema = mergeSchemas({ schemas: [ - { - name: 'Property', - schema: transformedPropertySchema, - }, - { - name: 'Booking', - schema: transformedBookingSchema, - }, - { - name: 'LinkSchema', - schema: linkSchema, - }, + transformedPropertySchema, + transformedBookingSchema, + linkSchema, ], resolvers: { Query: { diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 6e09031bdae..9ff64dbd9de 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -10,10 +10,7 @@ import { parse, ExecutionResult, } from 'graphql'; -import { VisitType } from '../Interfaces'; -import mergeSchemas, { - mergeSchemasImplementation, -} from '../stitching/mergeSchemas'; +import mergeSchemas from '../stitching/mergeSchemas'; import { propertySchema as localPropertySchema, productSchema as localProductSchema, @@ -304,46 +301,22 @@ testCombinations.forEach(async combination => { mergedSchema = mergeSchemas({ schemas: [ - { - name: 'Property', - schema: propertySchema, - }, - { - name: 'Booking', - schema: bookingSchema, - }, - { - name: 'Product', - schema: productSchema, - }, - { - name: 'ScalarTest', - schema: scalarTest, - }, - { - name: 'EnumTest', - schema: enumSchema, - }, - { - name: 'LinkSchema', - schema: linkSchema, - }, - { - name: 'LoneExtend', - schema: loneExtend, - }, - { - name: 'LocalSubscription', - schema: localSubscriptionSchema, - }, + propertySchema, + bookingSchema, + productSchema, + scalarTest, + enumSchema, + linkSchema, + loneExtend, + localSubscriptionSchema, ], resolvers: { Property: { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Booking', + return info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'bookingsByPropertyId', { @@ -360,8 +333,8 @@ testCombinations.forEach(async combination => { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'propertyById', { @@ -376,8 +349,8 @@ testCombinations.forEach(async combination => { LinkType: { property: { resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'propertyById', { @@ -391,8 +364,8 @@ testCombinations.forEach(async combination => { }, Query: { delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'interfaceTest', { @@ -403,8 +376,8 @@ testCombinations.forEach(async combination => { ); }, delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'propertyById', { @@ -424,8 +397,8 @@ testCombinations.forEach(async combination => { fragment: 'fragment NodeFragment on Node { id }', resolve(parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'propertyById', args, @@ -433,8 +406,8 @@ testCombinations.forEach(async combination => { info, ); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegate( - 'Booking', + return info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'bookingById', args, @@ -442,8 +415,8 @@ testCombinations.forEach(async combination => { info, ); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegate( - 'Booking', + return info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'customerById', args, @@ -456,16 +429,16 @@ testCombinations.forEach(async combination => { }, }, async nodes(parent, args, context, info) { - const bookings = await info.mergeInfo.delegate( - 'Booking', + const bookings = await info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'bookings', {}, context, info, ); - const properties = await info.mergeInfo.delegate( - 'Property', + const properties = await info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'properties', {}, @@ -1190,8 +1163,8 @@ bookingById(id: "b1") { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'bookingsByPropertyId', { @@ -1210,8 +1183,8 @@ bookingById(id: "b1") { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Booking', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'propertyById', { @@ -1237,8 +1210,8 @@ bookingById(id: "b1") { const Query2: IResolvers = { Query: { delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'interfaceTest', { @@ -1249,8 +1222,8 @@ bookingById(id: "b1") { ); }, delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'propertyById', { @@ -1270,8 +1243,8 @@ bookingById(id: "b1") { fragment: 'fragment NodeFragment on Node { id }', resolve(parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegate( - 'Property', + return info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'propertyById', args, @@ -1279,8 +1252,8 @@ bookingById(id: "b1") { info, ); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegate( - 'Booking', + return info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'bookingById', args, @@ -1288,8 +1261,8 @@ bookingById(id: "b1") { info, ); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegate( - 'Booking', + return info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'customerById', args, @@ -1307,16 +1280,16 @@ bookingById(id: "b1") { const AsyncQuery: IResolvers = { Query: { async nodes(parent, args, context, info) { - const bookings = await info.mergeInfo.delegate( - 'Booking', + const bookings = await info.mergeInfo.delegateToSchema( + bookingSchema, 'query', 'bookings', {}, context, info, ); - const properties = await info.mergeInfo.delegate( - 'Property', + const properties = await info.mergeInfo.delegateToSchema( + propertySchema, 'query', 'properties', {}, @@ -1329,38 +1302,14 @@ bookingById(id: "b1") { }; const schema = mergeSchemas({ schemas: [ - { - name: 'Property', - schema: propertySchema, - }, - { - name: 'Booking', - schema: bookingSchema, - }, - { - name: 'Product', - schema: productSchema, - }, - { - name: 'ScalarTest', - schema: scalarTest, - }, - { - name: 'EnumTest', - schema: enumSchema, - }, - { - name: 'LinkSchema', - schema: linkSchema, - }, - { - name: 'LoneExtend', - schema: loneExtend, - }, - { - name: 'LocalSubscription', - schema: localSubscriptionSchema, - }, + propertySchema, + bookingSchema, + productSchema, + scalarTest, + enumSchema, + linkSchema, + loneExtend, + localSubscriptionSchema, ], resolvers: [ Scalars, @@ -2560,118 +2509,3 @@ fragment BookingFragment on Booking { }); }); }); - -describe('mergeSchema options', () => { - describe('should filter types', () => { - let schema: GraphQLSchema; - - before(async () => { - const bookingSchema = await remoteBookingSchema; - const createTypeFilteringVisitTypes = ( - typeNames: Array, - ): VisitType => { - return (name, candidates) => { - if ( - ['ID', 'String', 'DateTime'].includes(name) || - typeNames.includes(name) - ) { - return candidates[candidates.length - 1].type; - } else { - return null; - } - }; - }; - schema = mergeSchemasImplementation({ - schemas: [ - { - name: 'Booking', - schema: bookingSchema, - }, - { - name: 'Selector', - schema: ` - type Query { - bookingById(id: ID!): Booking - }, - `, - }, - ], - visitType: createTypeFilteringVisitTypes(['Query', 'Booking']), - resolvers: { - Query: { - bookingById(parent, args, context, info) { - return info.mergeInfo.delegate( - 'Booking', - 'query', - 'bookingById', - args, - context, - info, - ); - }, - }, - }, - }); - }); - - it('should work normally', async () => { - const result = await graphql( - schema, - ` - query { - bookingById(id: "b1") { - id - propertyId - startTime - endTime - } - } - `, - ); - - expect(result).to.deep.equal({ - data: { - bookingById: { - endTime: '2016-06-03', - id: 'b1', - propertyId: 'p1', - startTime: '2016-05-04', - }, - }, - }); - }); - - it('should error on removed types', async () => { - const result = await graphql( - schema, - ` - query { - bookingById(id: "b1") { - id - propertyId - startTime - endTime - customer { - id - } - } - } - `, - ); - expect(result).to.deep.equal({ - errors: [ - { - locations: [ - { - column: 15, - line: 8, - }, - ], - message: 'Cannot query field "customer" on type "Booking".', - path: undefined, - }, - ], - }); - }); - }); -}); From 0db01231b61ec457f0b581d5aea12f740a0396f5 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 3 Apr 2018 13:34:16 +0300 Subject: [PATCH 32/57] Alpha 14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 78369719ed8..c03dcf98e4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.13", + "version": "3.0.0-alpha.14", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From 3b71ebca24ce0ef0f9b90374b5926b28fd328470 Mon Sep 17 00:00:00 2001 From: Michael Fix Date: Thu, 5 Apr 2018 12:32:11 -0500 Subject: [PATCH 33/57] graphql -> execute (#710) --- src/stitching/delegateToSchema.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 15476be061c..9ade3faeb24 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -10,8 +10,7 @@ import { SelectionSetNode, SelectionNode, subscribe, - graphql, - print, + execute, validate, VariableDefinitionNode, } from 'graphql'; @@ -66,9 +65,9 @@ export default async function delegateToSchema( } if (targetOperation === 'query' || targetOperation === 'mutation') { - const rawResult = await graphql( + const rawResult = await execute( targetSchema, - print(processedRequest.document), + processedRequest.document, info.rootValue, context, processedRequest.variables, From c3aaf924d88ca0b5f346cb3893e6ab2791bf2d6c Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Apr 2018 17:27:15 -0400 Subject: [PATCH 34/57] Rename makeTransformSchema to transformSchema. --- docs/source/schema-stitching.md | 4 ++-- docs/source/schema-transforms.md | 2 +- src/test/testAlternateMergeSchemas.ts | 6 +++--- src/test/testTransforms.ts | 8 ++++---- src/transforms/index.ts | 4 ++-- .../{makeTransformSchema.ts => transformSchema.ts} | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) rename src/transforms/{makeTransformSchema.ts => transformSchema.ts} (95%) diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index b801709b067..7ed4d93d351 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -192,7 +192,7 @@ import { addMockFunctionsToSchema, mergeSchemas, Transforms, - makeTransformSchema, + transformSchema, } from 'graphql-tools'; // Mocked chirp schema; we don't want to worry about the schema @@ -217,7 +217,7 @@ addMockFunctionsToSchema({ schema: chirpSchema }); // create transform schema -const transformedChirpSchema = makeTransformSchema(propertySchema, [ +const transformedChirpSchema = transformSchema(propertySchema, [ Transforms.FilterRootFields((operation: string, rootField: string) => ['Query.chirpById'].includes(`${operation}.${rootField}`), ), diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index 94c88e90b6f..5c3f4eb1be4 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -84,7 +84,7 @@ type Result = ExecutionResult & { }; ``` -

makeTransformSchema

+

transformSchema

Given a `GraphQLSchema` and an array of `Transform`, produce a new schema transformed by them. Also creates delegating resolvers mapping from new schema root fields to old schema root fields. Most of the time, when using transforms, you would be using this. diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index e7973f2e927..53149958090 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { graphql, GraphQLSchema } from 'graphql'; import mergeSchemas from '../stitching/mergeSchemas'; -import { Transforms, makeTransformSchema } from '../transforms'; +import { Transforms, transformSchema } from '../transforms'; import { propertySchema, bookingSchema } from './testingSchemas'; let linkSchema = ` @@ -55,14 +55,14 @@ describe('merge schemas through transforms', () => { before(async () => { // namespace and strip schemas - const transformedPropertySchema = makeTransformSchema(propertySchema, [ + const transformedPropertySchema = transformSchema(propertySchema, [ Transforms.FilterRootFields((operation: string, rootField: string) => ['Query.properties'].includes(`${operation}.${rootField}`), ), Transforms.RenameTypes((name: string) => `Properties_${name}`), Transforms.RenameRootFields((name: string) => `Properties_${name}`), ]); - const transformedBookingSchema = makeTransformSchema(bookingSchema, [ + const transformedBookingSchema = transformSchema(bookingSchema, [ Transforms.FilterRootFields((operation: string, rootField: string) => ['Query.bookings'].includes(`${operation}.${rootField}`), ), diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index 1a0f702515c..77ba2178e64 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { GraphQLSchema, GraphQLNamedType, graphql } from 'graphql'; import { propertySchema, bookingSchema } from './testingSchemas'; -import { Transforms, makeTransformSchema } from '../transforms'; +import { Transforms, transformSchema } from '../transforms'; describe('transforms', () => { describe('rename type', () => { @@ -23,7 +23,7 @@ describe('transforms', () => { }[name]), ), ]; - schema = makeTransformSchema(propertySchema, transforms); + schema = transformSchema(propertySchema, transforms); }); it('should work', async () => { const result = await graphql( @@ -74,7 +74,7 @@ describe('transforms', () => { const transforms = [ Transforms.RenameTypes((name: string) => `Property_${name}`), ]; - schema = makeTransformSchema(propertySchema, transforms); + schema = transformSchema(propertySchema, transforms); }); it('should work', async () => { const result = await graphql( @@ -127,7 +127,7 @@ describe('transforms', () => { ['ID', 'String', 'DateTime', 'Query', 'Booking'].includes(type.name), ), ]; - schema = makeTransformSchema(bookingSchema, transforms); + schema = transformSchema(bookingSchema, transforms); }); it('should work normally', async () => { diff --git a/src/transforms/index.ts b/src/transforms/index.ts index b138605e487..90637e0d90e 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -3,7 +3,7 @@ import CheckResultAndHandleErrors from './CheckResultAndHandleErrors'; import ReplaceFieldWithFragment from './ReplaceFieldWithFragment'; import AddTypenameToAbstract from './AddTypenameToAbstract'; import FilterToSchema from './FilterToSchema'; -import makeTransformSchema from './makeTransformSchema'; +import transformSchema from './transformSchema'; import RenameTypes from './RenameTypes'; import FilterTypes from './FilterTypes'; import TransformRootFields from './TransformRootFields'; @@ -12,7 +12,7 @@ import FilterRootFields from './FilterRootFields'; import ExpandAbstractTypes from './ExpandAbstractTypes'; export * from './transforms'; export * from './visitSchema'; -export { makeTransformSchema }; +export { transformSchema }; export const Transforms: { [name: string]: any } = { AddArgumentsAsVariables, CheckResultAndHandleErrors, diff --git a/src/transforms/makeTransformSchema.ts b/src/transforms/transformSchema.ts similarity index 95% rename from src/transforms/makeTransformSchema.ts rename to src/transforms/transformSchema.ts index cc00fbcd49f..238c193fa6b 100644 --- a/src/transforms/makeTransformSchema.ts +++ b/src/transforms/transformSchema.ts @@ -8,7 +8,7 @@ import { generateSimpleMapping, } from '../stitching/resolvers'; -export default function makeTransformSchema( +export default function transformSchema( targetSchema: GraphQLSchema, transforms: Array, ): GraphQLSchema & { transforms: Array } { From cf2c9212c97a82a35a5f56a927b0f4e004be39d7 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Apr 2018 17:34:48 -0400 Subject: [PATCH 35/57] Add back Travis CI testing in Node 4. --- .travis.yml | 1 + src/test/testAlternateMergeSchemas.ts | 4 ++-- src/test/testTransforms.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index e33932e3409..65a50a948ea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ language: node_js node_js: + - "4" - "6" - "8" diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index 53149958090..c470084dd8b 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -57,14 +57,14 @@ describe('merge schemas through transforms', () => { // namespace and strip schemas const transformedPropertySchema = transformSchema(propertySchema, [ Transforms.FilterRootFields((operation: string, rootField: string) => - ['Query.properties'].includes(`${operation}.${rootField}`), + 'Query.properties' === `${operation}.${rootField}`, ), Transforms.RenameTypes((name: string) => `Properties_${name}`), Transforms.RenameRootFields((name: string) => `Properties_${name}`), ]); const transformedBookingSchema = transformSchema(bookingSchema, [ Transforms.FilterRootFields((operation: string, rootField: string) => - ['Query.bookings'].includes(`${operation}.${rootField}`), + 'Query.bookings' === `${operation}.${rootField}`, ), Transforms.RenameTypes((name: string) => `Bookings_${name}`), Transforms.RenameRootFields( diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index 77ba2178e64..3133aad490b 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -122,9 +122,10 @@ describe('transforms', () => { describe('filter type', () => { let schema: GraphQLSchema; before(() => { + const typeNames = ['ID', 'String', 'DateTime', 'Query', 'Booking']; const transforms = [ Transforms.FilterTypes((type: GraphQLNamedType) => - ['ID', 'String', 'DateTime', 'Query', 'Booking'].includes(type.name), + typeNames.indexOf(type.name) >= 0 ), ]; schema = transformSchema(bookingSchema, transforms); From b740edeab1b281ca0e34c6514c1f3b3efea2f8f8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Apr 2018 18:01:02 -0400 Subject: [PATCH 36/57] Limit transforms/index.ts re-exports as much as possible. I think we should avoid exporting anything until we know it's needed elsewhere, since otherwise it's difficult to tell if an unused export is safe to remove, and we have to document a larger public API. --- src/transforms/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/transforms/index.ts b/src/transforms/index.ts index 90637e0d90e..952d3da818c 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -1,19 +1,23 @@ +import { Transform } from './transforms'; +export { Transform }; + +export { default as transformSchema } from './transformSchema'; + import AddArgumentsAsVariables from './AddArgumentsAsVariables'; import CheckResultAndHandleErrors from './CheckResultAndHandleErrors'; import ReplaceFieldWithFragment from './ReplaceFieldWithFragment'; import AddTypenameToAbstract from './AddTypenameToAbstract'; import FilterToSchema from './FilterToSchema'; -import transformSchema from './transformSchema'; import RenameTypes from './RenameTypes'; import FilterTypes from './FilterTypes'; import TransformRootFields from './TransformRootFields'; import RenameRootFields from './RenameRootFields'; import FilterRootFields from './FilterRootFields'; import ExpandAbstractTypes from './ExpandAbstractTypes'; -export * from './transforms'; -export * from './visitSchema'; -export { transformSchema }; -export const Transforms: { [name: string]: any } = { + +export const Transforms: { + [name: string]: (...args: any[]) => Transform +} = { AddArgumentsAsVariables, CheckResultAndHandleErrors, ReplaceFieldWithFragment, From 55eb147cf6a7f2471f320f0079924449a248ff0d Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 4 Apr 2018 18:56:30 -0400 Subject: [PATCH 37/57] First pass at editing schema-delegation.md. --- docs/source/schema-delegation.md | 69 ++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md index c4ec11a4575..d2138ce0ea3 100644 --- a/docs/source/schema-delegation.md +++ b/docs/source/schema-delegation.md @@ -3,17 +3,19 @@ title: Schema delegation description: Forward queries to other schemas automatically --- -Schema delegation is a way to automatically forward query or a part of the query from the schema one is executing now to another schema called `subschema`. Delegation is useful when your parent schema shares big part of its model with the subschema. This is often the case when, eg, building a GraphQL gateway by connecting multiple schemas together. Several useful tools exist to work together with delegation in `graphql-tools`: +Schema delegation is a way to automatically forward a query (or a part of a query) from a parent schema to another schema (called a _subschema_) that is able to execute the query. Delegation is useful when the parent schema shares a significant part of its data model with the subschema. For example, the parent schema might be powering a GraphQL gateway that connects multiple existing endpoints together, each with its own schema. This kind of architecture could be implemented using schema delegation. -* [Remote schemas](./remote-schemas.html) - turning a remote GraphQL endpoint into a local GraphQL schema -* [Schema transforms](./schema-transforms.html) - manipulating with schemas, while keep ability to delegate to them -* [Schema stitching](./schema-stitching) - extending schemas and connecting multiple schemas together +The `graphql-tools` package provides several related tools for managing schema delegation: -Delegation is performed by one function - `delegateToSchema`. It should be called from within a parent schema resolver. It uses the GraphQL query tree starts at the resolver to create a query that will be executed on a subschema. +* [Remote schemas](./remote-schemas.html) - turning a remote GraphQL endpoint into a local schema +* [Schema transforms](./schema-transforms.html) - modifying existing schemas to make delegation easier +* [Schema stitching](./schema-stitching) - merging multiple schemas into one + +Delegation is performed by one function, `delegateToSchema`, called from within a resolver function of the parent schema. The `delegateToSchema` function sends the query subtree received by the parent resolver to a subschema that knows how to execute it, then returns the result as if the parent resolver had executed the query.

Motivational example

-Let's consider our schemas, a subschema and a parent schema that reuses parts of a subschema. While parent schema reuses the *definitions* of the subschema, we want to keep implementation separate. This way the subschema can be tested and used separately or even be a remote service. +Let's consider two schemas, a subschema and a parent schema that reuses parts of a subschema. While the parent schema reuses the *definitions* of the subschema, we want to keep the implementations separate, so that the subschema can be tested independently, or even used as a remote service. ```graphql # Subschema @@ -35,7 +37,7 @@ type Query { repositoriesByUserId(id: ID!): [Repository] } -# Schema +# Parent schema type Repository { id: ID! url: String @@ -61,7 +63,7 @@ type Query { } ``` -We want parent schema to delegate retrieval of repositories to the subschema. Assuming a query as following: +Suppose we want the parent schema to delegate retrieval of repositories to the subschema, in order to execute queries such as this one: ```graphql query { @@ -80,7 +82,7 @@ query { } ``` -At resolver for `repositories`, we would delegate. While it's possible to simply call a graphql endpoint of this schema or execute manually, this would require us to manually convert the query or always fetch all possible fields, which could lead to overfetching. Delegation automatically extracts the query. +The resolver function for the `repositories` field of the `User` type would be responsible for the delegation, in this case. While it's possible to call a remote GraphQL endpoint or resolve the data manually, this would require us to transform the query manually, or always fetch all possible fields, which could lead to overfetching. Delegation automatically extracts the appropriate query to send to the subschema: ```graphql # To the subschema @@ -95,7 +97,7 @@ query($id: ID!) { } ``` -Delegation also removes the fields that don't exist on the subschema, such as user. This field would be retrieved on our parent schema using normal GraphQL resolvers. +Delegation also removes the fields that don't exist on the subschema, such as `user`. This field would be retrieved from the parent schema using normal GraphQL resolvers.

API

@@ -103,9 +105,9 @@ Delegation also removes the fields that don't exist on the subschema, such as us ``` function delegateToSchema( - targetSchema: GraphQLSchema, - targetOperation: 'query' | 'mutation' | 'subscription', - targetField: string, + schema: GraphQLSchema, + type: 'query' | 'mutation' | 'subscription', + fieldName: string, args: { [key: string]: any }, context: { [key: string]: any }, info: GraphQLResolveInfo, @@ -113,21 +115,21 @@ function delegateToSchema( ): Promise ``` -#### targetSchema: GraphQLSchema +#### schema: GraphQLSchema A subschema to delegate to. -#### targetOperation: 'query' | 'mutation' | 'subscription' +#### type: 'query' | 'mutation' | 'subscription' An operation to use during the delegation. -#### targetField: string +#### fieldName: string A root field in a subschema from which the query should start. #### args: { [key: string]: any } -Additional arguments to be passed to the field. Arguments on the field that is being resolved are going to be kept if they are valid, this allows adding additional arguments or overriding them. For example: +Additional arguments to be passed to the field. Arguments on the field that is being resolved are going to be kept if they are valid, which allows adding additional arguments or overriding existing ones. For example: ```graphql # Subschema @@ -152,21 +154,27 @@ type Booking { } ``` -If we are to delegate at `User.bookings` to `bookingsByUser`, we want to preserve the `limit` argument and add an `userId` argument by using the `User.id`. So the resolrver would look like the following: +If we delegate at `User.bookings` to `Query.bookingsByUser`, we want to preserve the `limit` argument and add an `userId` argument by using the `User.id`. So the resolver would look like the following: ```js -bookings(parent, args, context, info) { - return delegateToSchema( - subschema, - 'query', - 'bookingsByUser', - { - userId: parent.id, +const resolvers = { + User: { + bookings(parent, args, context, info) { + return info.mergeInfo.delegateToSchema( + subschema, + 'query', + 'bookingsByUser', + { + userId: parent.id, + }, + context, + info, + ); }, - context, - info, - ); -} + ... + }, + ... +}; ``` #### context: { [key: string]: any } @@ -179,8 +187,7 @@ GraphQL resolve info of the current resolver. Used to get the query that starts #### transforms: Array -[Transforms](./transforms.html) to apply to the query and results. Should be the -same transformed that were used to transform the schema, if any. One can use `transformedSchema.transforms` to retrieve transforms. +[Transforms](./transforms.html) to apply to the query and results. Should be the same transforms that were used to transform the schema, if any. One can use `transformedSchema.transforms` to retrieve transforms.

Additional considerations

From 8fb3b452218b7df7d3f2e00d7ebaebdfe0c55ee8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 5 Apr 2018 10:42:49 -0400 Subject: [PATCH 38/57] Make delegateToSchema take named rather than positional parameters. --- docs/source/schema-delegation.md | 34 ++-- src/Interfaces.ts | 24 +-- src/stitching/delegateToSchema.ts | 54 +++---- src/stitching/index.ts | 3 +- src/stitching/mergeSchemas.ts | 57 ++++--- src/stitching/resolvers.ts | 25 ++- src/test/testAlternateMergeSchemas.ts | 64 ++++---- src/test/testMergeSchemas.ts | 216 +++++++++++++------------- 8 files changed, 234 insertions(+), 243 deletions(-) diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md index d2138ce0ea3..1f8b62142d6 100644 --- a/docs/source/schema-delegation.md +++ b/docs/source/schema-delegation.md @@ -103,23 +103,25 @@ Delegation also removes the fields that don't exist on the subschema, such as `u

delegateToSchema

+The `delegateToSchema` method can be found on the `info.mergeInfo` object within any resolver function, and should be called with the following named options: + ``` -function delegateToSchema( - schema: GraphQLSchema, - type: 'query' | 'mutation' | 'subscription', - fieldName: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, - transforms?: Array, -): Promise +delegateToSchema(options: { + schema: GraphQLSchema; + operation: 'query' | 'mutation' | 'subscription'; + fieldName: string; + args: { [key: string]: any }; + context: { [key: string]: any }; + info: GraphQLResolveInfo; + transforms?: Array; +}): Promise ``` #### schema: GraphQLSchema A subschema to delegate to. -#### type: 'query' | 'mutation' | 'subscription' +#### operation: 'query' | 'mutation' | 'subscription' An operation to use during the delegation. @@ -129,7 +131,7 @@ A root field in a subschema from which the query should start. #### args: { [key: string]: any } -Additional arguments to be passed to the field. Arguments on the field that is being resolved are going to be kept if they are valid, which allows adding additional arguments or overriding existing ones. For example: +Additional arguments to be passed to the field. Arguments passed to the field that is being resolved will be preserved if the subschema expects them, so you don't have to pass existing arguments explicitly, though you could use the additional arguments to override the existing ones. For example: ```graphql # Subschema @@ -160,11 +162,11 @@ If we delegate at `User.bookings` to `Query.bookingsByUser`, we want to preserve const resolvers = { User: { bookings(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - subschema, - 'query', - 'bookingsByUser', - { + return info.mergeInfo.delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'bookingsByUser', + args: { userId: parent.id, }, context, diff --git a/src/Interfaces.ts b/src/Interfaces.ts index c7910f7927c..b5ba8f7a536 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -37,6 +37,18 @@ export type Transform = { transformResult?: (result: Result) => Result; }; +export interface IDelegateToSchemaOptions< + TContext = { [key: string]: any }, +> { + schema: GraphQLSchema; + operation: Operation; + fieldName: string; + args: { [key: string]: any }; + context: TContext; + info: GraphQLResolveInfo; + transforms?: Array; +} + export type MergeInfo = { delegate: ( type: 'query' | 'mutation' | 'subscription', @@ -46,15 +58,9 @@ export type MergeInfo = { info: GraphQLResolveInfo, transforms?: Array, ) => any; - delegateToSchema: ( - schema: GraphQLSchema, - type: 'query' | 'mutation' | 'subscription', - fieldName: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, - transforms?: Array, - ) => any; + delegateToSchema( + options: IDelegateToSchemaOptions + ): any, }; export type IFieldResolver = ( diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 9ade3faeb24..73a086be57d 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -3,8 +3,6 @@ import { DocumentNode, FieldNode, FragmentDefinitionNode, - GraphQLResolveInfo, - GraphQLSchema, Kind, OperationDefinitionNode, SelectionSetNode, @@ -14,9 +12,8 @@ import { validate, VariableDefinitionNode, } from 'graphql'; -import { Operation, Request } from '../Interfaces'; +import { Operation, Request, IDelegateToSchemaOptions } from '../Interfaces'; import { - Transform, applyRequestTransforms, applyResultTransforms, } from '../transforms/transforms'; @@ -26,17 +23,12 @@ import AddTypenameToAbstract from '../transforms/AddTypenameToAbstract'; import CheckResultAndHandleErrors from '../transforms/CheckResultAndHandleErrors'; export default async function delegateToSchema( - targetSchema: GraphQLSchema, - targetOperation: Operation, - targetField: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, - transforms?: Array, + options: IDelegateToSchemaOptions, ): Promise { + const { info } = options; const rawDocument: DocumentNode = createDocument( - targetField, - targetOperation, + options.fieldName, + options.operation, info.fieldNodes, Object.keys(info.fragments).map( fragmentName => info.fragments[fragmentName], @@ -49,47 +41,45 @@ export default async function delegateToSchema( variables: info.variableValues as Record, }; - transforms = [ - ...(transforms || []), - AddArgumentsAsVariables(targetSchema, args), - FilterToSchema(targetSchema), - AddTypenameToAbstract(targetSchema), - CheckResultAndHandleErrors(info, targetField), + const transforms = [ + ...(options.transforms || []), + AddArgumentsAsVariables(options.schema, options.args), + FilterToSchema(options.schema), + AddTypenameToAbstract(options.schema), + CheckResultAndHandleErrors(info, options.fieldName), ]; const processedRequest = applyRequestTransforms(rawRequest, transforms); - const errors = validate(targetSchema, processedRequest.document); + const errors = validate(options.schema, processedRequest.document); if (errors.length > 0) { throw errors; } - if (targetOperation === 'query' || targetOperation === 'mutation') { - const rawResult = await execute( - targetSchema, + if (options.operation === 'query' || + options.operation === 'mutation') { + return applyResultTransforms(await execute( + options.schema, processedRequest.document, info.rootValue, - context, + options.context, processedRequest.variables, - ); - - const result = applyResultTransforms(rawResult, transforms); - return result; + ), transforms); } - if (targetOperation === 'subscription') { + if (options.operation === 'subscription') { // apply result processing ??? return subscribe( - targetSchema, + options.schema, processedRequest.document, info.rootValue, - context, + options.context, processedRequest.variables, ); } } -export function createDocument( +function createDocument( targetField: string, targetOperation: Operation, originalSelections: Array, diff --git a/src/stitching/index.ts b/src/stitching/index.ts index 02e21bcf3de..6cab13fbf31 100644 --- a/src/stitching/index.ts +++ b/src/stitching/index.ts @@ -1,7 +1,7 @@ import makeRemoteExecutableSchema from './makeRemoteExecutableSchema'; import introspectSchema from './introspectSchema'; import mergeSchemas from './mergeSchemas'; -import delegateToSchema, { createDocument } from './delegateToSchema'; +import delegateToSchema from './delegateToSchema'; import defaultMergedResolver from './defaultMergedResolver'; export { @@ -11,6 +11,5 @@ export { // Those are currently undocumented and not part of official API, // but exposed for the community use delegateToSchema, - createDocument, defaultMergedResolver, }; diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 29f96cf0bab..e1e0097f5ae 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -24,6 +24,7 @@ import { MergeTypeCandidate, TypeWithResolvers, VisitTypeResult, + IDelegateToSchemaOptions, } from '../Interfaces'; import { extractExtensionDefinitions, @@ -327,42 +328,36 @@ function createMergeInfo( schema, fragmentReplacements, ); - return delegateToSchema( + return delegateToSchema({ schema, operation, fieldName, args, context, info, - [...(transforms || []), expandTransforms, fragmentTransform], - ); + transforms: [ + ...(transforms || []), + expandTransforms, + fragmentTransform, + ], + }); }, - delegateToSchema( - schema: GraphQLSchema, - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, - transforms?: Array, - ) { - const expandTransforms = Transforms.ExpandAbstractTypes( - info.schema, - schema, - ); - const fragmentTransform = Transforms.ReplaceFieldWithFragment( - schema, - fragmentReplacements, - ); - return delegateToSchema( - schema, - operation, - fieldName, - args, - context, - info, - [...(transforms || []), expandTransforms, fragmentTransform], - ); + + delegateToSchema(options: IDelegateToSchemaOptions) { + return delegateToSchema({ + ...options, + transforms: [ + ...(options.transforms || []), + Transforms.ExpandAbstractTypes( + options.info.schema, + options.schema, + ), + Transforms.ReplaceFieldWithFragment( + options.schema, + fragmentReplacements, + ), + ], + }); }, }; } @@ -399,14 +394,14 @@ function createDelegatingResolver( fieldName: string, ): IFieldResolver { return (root, args, context, info) => { - return info.mergeInfo.delegateToSchema( + return info.mergeInfo.delegateToSchema({ schema, operation, fieldName, args, context, info, - ); + }); }; } diff --git a/src/stitching/resolvers.ts b/src/stitching/resolvers.ts index 88020a6cd17..9a41972c468 100644 --- a/src/stitching/resolvers.ts +++ b/src/stitching/resolvers.ts @@ -85,19 +85,18 @@ export function generateMappingFromObjectType( } function createProxyingResolver( - targetSchema: GraphQLSchema, - targetOperation: Operation, - targetField: string, + schema: GraphQLSchema, + operation: Operation, + fieldName: string, transforms: Array, ): GraphQLFieldResolver { - return (parent, args, context, info) => - delegateToSchema( - targetSchema, - targetOperation, - targetField, - {}, - context, - info, - transforms, - ); + return (parent, args, context, info) => delegateToSchema({ + schema, + operation, + fieldName, + args: {}, + context, + info, + transforms, + }); } diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index c470084dd8b..f964773284e 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -83,35 +83,35 @@ describe('merge schemas through transforms', () => { // delegating directly, no subschemas or mergeInfo node(parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', args, context, info, - transformedPropertySchema.transforms, - ); + transforms: transformedPropertySchema.transforms, + }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookingById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingById', args, context, info, - transformedBookingSchema.transforms, - ); + transforms: transformedBookingSchema.transforms, + }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'customerById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'customerById', args, context, info, - transformedBookingSchema.transforms, - ); + transforms: transformedBookingSchema.transforms, + }); } else { throw new Error('invalid id'); } @@ -121,18 +121,18 @@ describe('merge schemas through transforms', () => { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookingsByPropertyId', - { + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingsByPropertyId', + args: { propertyId: parent.id, limit: args.limit ? args.limit : null, }, context, info, - transformedBookingSchema.transforms, - ); + transforms: transformedBookingSchema.transforms, + }); }, }, }, @@ -140,17 +140,17 @@ describe('merge schemas through transforms', () => { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: parent.propertyId, }, context, info, - transformedPropertySchema.transforms, - ); + transforms: transformedPropertySchema.transforms, + }); }, }, }, diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index bd7434b911c..d4e44f261e5 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -336,17 +336,17 @@ testCombinations.forEach(async combination => { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookingsByPropertyId', - { + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingsByPropertyId', + args: { propertyId: parent.id, limit: args.limit ? args.limit : null, }, context, info, - ); + }); }, }, }, @@ -354,16 +354,16 @@ testCombinations.forEach(async combination => { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: parent.propertyId, }, context, info, - ); + }); }, }, }, @@ -375,43 +375,43 @@ testCombinations.forEach(async combination => { LinkType: { property: { resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: 'p1', }, context, info, - ); + }); }, }, }, Query: { delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'interfaceTest', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'interfaceTest', + args: { kind: 'ONE', }, context, info, - ); + }); }, delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: 'p1', }, context, info, - ); + }); }, linkTest() { return { @@ -423,54 +423,54 @@ testCombinations.forEach(async combination => { fragment: 'fragment NodeFragment on Node { id }', resolve(parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', args, context, info, - ); + }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookingById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingById', args, context, info, - ); + }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'customerById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'customerById', args, context, info, - ); + }); } else { throw new Error('invalid id'); } }, }, async nodes(parent, args, context, info) { - const bookings = await info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookings', - {}, + const bookings = await info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookings', + args: {}, context, info, - ); - const properties = await info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'properties', - {}, + }); + const properties = await info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'properties', + args: {}, context, info, - ); + }); return [...bookings, ...properties]; }, }, @@ -1189,17 +1189,17 @@ bookingById(id: "b1") { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookingsByPropertyId', - { + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingsByPropertyId', + args: { propertyId: parent.id, limit: args.limit ? args.limit : null, }, context, info, - ); + }); }, }, }, @@ -1209,16 +1209,16 @@ bookingById(id: "b1") { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: parent.propertyId, }, context, info, - ); + }); }, }, }, @@ -1236,28 +1236,28 @@ bookingById(id: "b1") { const Query2: IResolvers = { Query: { delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'interfaceTest', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'interfaceTest', + args: { kind: 'ONE', }, context, info, - ); + }); }, delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', - { + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: 'p1', }, context, info, - ); + }); }, linkTest() { return { @@ -1269,32 +1269,32 @@ bookingById(id: "b1") { fragment: 'fragment NodeFragment on Node { id }', resolve(parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'propertyById', + return info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'propertyById', args, context, info, - ); + }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookingById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingById', args, context, info, - ); + }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'customerById', + return info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'customerById', args, context, info, - ); + }); } else { throw new Error('invalid id'); } @@ -1306,22 +1306,22 @@ bookingById(id: "b1") { const AsyncQuery: IResolvers = { Query: { async nodes(parent, args, context, info) { - const bookings = await info.mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'bookings', - {}, + const bookings = await info.mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookings', + args: {}, context, info, - ); - const properties = await info.mergeInfo.delegateToSchema( - propertySchema, - 'query', - 'properties', - {}, + }); + const properties = await info.mergeInfo.delegateToSchema({ + schema: propertySchema, + operation: 'query', + fieldName: 'properties', + args: {}, context, info, - ); + }); return [...bookings, ...properties]; }, }, From dbee26012d36f8e67c36a09d8e73386db3a63372 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 5 Apr 2018 12:34:57 -0400 Subject: [PATCH 39/57] Make additional args optional when calling delegateToSchema. Since these are just additional arguments, it should be possible to omit args when no additional arguments need to be passed to the subschema. --- docs/source/schema-delegation.md | 2 +- src/Interfaces.ts | 2 +- src/stitching/delegateToSchema.ts | 4 ++-- src/test/testMergeSchemas.ts | 4 ---- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md index 1f8b62142d6..24843990c7b 100644 --- a/docs/source/schema-delegation.md +++ b/docs/source/schema-delegation.md @@ -110,7 +110,7 @@ delegateToSchema(options: { schema: GraphQLSchema; operation: 'query' | 'mutation' | 'subscription'; fieldName: string; - args: { [key: string]: any }; + args?: { [key: string]: any }; context: { [key: string]: any }; info: GraphQLResolveInfo; transforms?: Array; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index b5ba8f7a536..91dc99416b8 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -43,7 +43,7 @@ export interface IDelegateToSchemaOptions< schema: GraphQLSchema; operation: Operation; fieldName: string; - args: { [key: string]: any }; + args?: { [key: string]: any }; context: TContext; info: GraphQLResolveInfo; transforms?: Array; diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 73a086be57d..8e4258aaeb4 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -25,7 +25,7 @@ import CheckResultAndHandleErrors from '../transforms/CheckResultAndHandleErrors export default async function delegateToSchema( options: IDelegateToSchemaOptions, ): Promise { - const { info } = options; + const { info, args = {} } = options; const rawDocument: DocumentNode = createDocument( options.fieldName, options.operation, @@ -43,7 +43,7 @@ export default async function delegateToSchema( const transforms = [ ...(options.transforms || []), - AddArgumentsAsVariables(options.schema, options.args), + AddArgumentsAsVariables(options.schema, args), FilterToSchema(options.schema), AddTypenameToAbstract(options.schema), CheckResultAndHandleErrors(info, options.fieldName), diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index d4e44f261e5..9b4b0572776 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -459,7 +459,6 @@ testCombinations.forEach(async combination => { schema: bookingSchema, operation: 'query', fieldName: 'bookings', - args: {}, context, info, }); @@ -467,7 +466,6 @@ testCombinations.forEach(async combination => { schema: propertySchema, operation: 'query', fieldName: 'properties', - args: {}, context, info, }); @@ -1310,7 +1308,6 @@ bookingById(id: "b1") { schema: bookingSchema, operation: 'query', fieldName: 'bookings', - args: {}, context, info, }); @@ -1318,7 +1315,6 @@ bookingById(id: "b1") { schema: propertySchema, operation: 'query', fieldName: 'properties', - args: {}, context, info, }); From cabbd94f297ef06cfff3d9157947f3ad40dee32a Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 17 Apr 2018 11:37:30 +0300 Subject: [PATCH 40/57] Remove transform warnings --- src/transforms/transformSchema.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/transforms/transformSchema.ts b/src/transforms/transformSchema.ts index 238c193fa6b..e8dcdcb1e31 100644 --- a/src/transforms/transformSchema.ts +++ b/src/transforms/transformSchema.ts @@ -19,8 +19,12 @@ export default function transformSchema( transforms, mapping, ); - addResolveFunctionsToSchema(schema, resolvers, { - allowResolversNotInSchema: true, + addResolveFunctionsToSchema({ + schema, + resolvers, + resolverValidationOptions: { + allowResolversNotInSchema: true, + }, }); schema = applySchemaTransforms(schema, transforms); (schema as any).transforms = transforms; From 2e1420980c99659bbebf54a4086c5cbe6f07e674 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 17 Apr 2018 11:37:48 +0300 Subject: [PATCH 41/57] Alpha 15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 08881a3d629..edf2779afcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.14", + "version": "3.0.0-alpha.15", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From 663c9af4cad84e54ae83b059228730f154536741 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 17 Apr 2018 12:29:07 +0300 Subject: [PATCH 42/57] Don't overuse fragments that won't be valid --- src/stitching/delegateToSchema.ts | 20 +++++----- src/test/testMergeSchemas.ts | 66 ++++++++++++++++++++++++++++--- src/transforms/FilterToSchema.ts | 65 +++++++++++++++--------------- 3 files changed, 105 insertions(+), 46 deletions(-) diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 8e4258aaeb4..123830acc54 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -56,15 +56,17 @@ export default async function delegateToSchema( throw errors; } - if (options.operation === 'query' || - options.operation === 'mutation') { - return applyResultTransforms(await execute( - options.schema, - processedRequest.document, - info.rootValue, - options.context, - processedRequest.variables, - ), transforms); + if (options.operation === 'query' || options.operation === 'mutation') { + return applyResultTransforms( + await execute( + options.schema, + processedRequest.document, + info.rootValue, + options.context, + processedRequest.variables, + ), + transforms, + ); } if (options.operation === 'subscription') { diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 164a26e0df8..b4aaf6ce43e 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -182,7 +182,6 @@ const loneExtend = ` } `; - let interfaceExtensionTest = ` # No-op for older versions since this feature does not yet exist extend type DownloadableProduct { @@ -370,7 +369,7 @@ testCombinations.forEach(async combination => { DownloadableProduct: { filesize() { return 1024; - } + }, }, LinkType: { property: { @@ -1624,6 +1623,63 @@ fragment BookingFragment on Booking { }); }); + it('containing fragment on outer type', async () => { + const mergedResult = await graphql( + mergedSchema, + ` + query { + propertyById(id: "p2") { + id + ... on Property { + name + ...BookingFragment1 + } + } + } + + fragment BookingFragment1 on Property { + bookings { + id + property { + id + name + } + } + ...BookingFragment2 + } + + fragment BookingFragment2 on Property { + bookings { + customer { + name + } + } + } + `, + ); + + expect(mergedResult).to.deep.equal({ + data: { + propertyById: { + id: 'p2', + name: 'Another great hotel', + bookings: [ + { + id: 'b4', + customer: { + name: 'Exampler Customer', + }, + property: { + id: 'p2', + name: 'Another great hotel', + }, + }, + ], + }, + }, + }); + }); + it('containing links and overlapping fragments on relation', async () => { const mergedResult = await graphql( mergedSchema, @@ -2474,10 +2530,10 @@ fragment BookingFragment on Booking { { id: 'pd2', __typename: 'DownloadableProduct', - filesize: 1024 + filesize: 1024, }, - ] - } + ], + }, }); }); } diff --git a/src/transforms/FilterToSchema.ts b/src/transforms/FilterToSchema.ts index ef1ef2c2a87..f9c65f37576 100644 --- a/src/transforms/FilterToSchema.ts +++ b/src/transforms/FilterToSchema.ts @@ -73,34 +73,6 @@ function filterDocumentToSchema( validFragmentsWithType[fragment.name.value] = type; }); - validFragments.forEach((fragment: FragmentDefinitionNode) => { - const name = fragment.name.value; - const typeName = fragment.typeCondition.name.value; - const type = targetSchema.getType(typeName); - const { - selectionSet, - usedFragments: fragmentUsedFragments, - usedVariables: fragmentUsedVariables, - } = filterSelectionSet( - targetSchema, - type, - validFragmentsWithType, - fragment.selectionSet, - ); - usedFragments = union(usedFragments, fragmentUsedFragments); - usedVariables = union(usedVariables, fragmentUsedVariables); - - newFragments.push({ - kind: Kind.FRAGMENT_DEFINITION, - name: { - kind: Kind.NAME, - value: name, - }, - typeCondition: fragment.typeCondition, - selectionSet, - }); - }); - operations.forEach((operation: OperationDefinitionNode) => { let type; if (operation.operation === 'subscription') { @@ -139,10 +111,39 @@ function filterDocumentToSchema( }); }); - newFragments = newFragments.filter( - (fragment: FragmentDefinitionNode) => - usedFragments.indexOf(fragment.name.value) !== -1, - ); + while (usedFragments.length !== 0) { + const nextFragmentName = usedFragments.pop(); + const fragment = validFragments.find( + fr => fr.name.value === nextFragmentName, + ); + if (fragment) { + const name = nextFragmentName; + const typeName = fragment.typeCondition.name.value; + const type = targetSchema.getType(typeName); + const { + selectionSet, + usedFragments: fragmentUsedFragments, + usedVariables: fragmentUsedVariables, + } = filterSelectionSet( + targetSchema, + type, + validFragmentsWithType, + fragment.selectionSet, + ); + usedFragments = union(usedFragments, fragmentUsedFragments); + usedVariables = union(usedVariables, fragmentUsedVariables); + + newFragments.push({ + kind: Kind.FRAGMENT_DEFINITION, + name: { + kind: Kind.NAME, + value: name, + }, + typeCondition: fragment.typeCondition, + selectionSet, + }); + } + } return { kind: Kind.DOCUMENT, From fa1228cbe59a11f030f7c78a1a48f871774de7c8 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Tue, 17 Apr 2018 12:29:44 +0300 Subject: [PATCH 43/57] Alpha 16 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index edf2779afcb..c0f9139965e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql-tools", - "version": "3.0.0-alpha.15", + "version": "3.0.0-alpha.16", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", "typings": "dist/index.d.ts", From 101fd40c2476b9f458a5dd7704182c0ef92961c8 Mon Sep 17 00:00:00 2001 From: Igor Kuzmitshov Date: Wed, 18 Apr 2018 13:44:44 +0300 Subject: [PATCH 44/57] Fix examples of delegateToSchema (#742) --- docs/source/schema-stitching.md | 97 +++++++++++++++++---------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 7ed4d93d351..635ec547a72 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -134,23 +134,22 @@ mergeSchemas({ authorSchema, linkTypeDefs, ], - ], resolvers: { User: { chirps: { fragment: `fragment UserFragment on User { id }`, resolve(parent, args, context, info) { const authorId = parent.id; - return info.mergeInfo.delegateToSchema( - chirpSchema, - 'query', - 'chirpsByAuthorId', - { + return info.mergeInfo.delegateToSchema({ + schema: chirpSchema, + operation: 'query', + fieldName: 'chirpsByAuthorId', + args: { authorId, }, context, info, - ); + }); }, }, }, @@ -159,16 +158,16 @@ mergeSchemas({ fragment: `fragment ChirpFragment on Chirp { authorId }`, resolve(parent, args, context, info) { const id = parent.authorId; - return info.mergeInfo.delegateToSchema( - productSchema, - 'query', - 'userById', - { + return info.mergeInfo.delegateToSchema({ + schema: authorSchema, + operation: 'query', + fieldName: 'userById', + args: { id, }, context, info, - ); + }); }, }, }, @@ -217,7 +216,7 @@ addMockFunctionsToSchema({ schema: chirpSchema }); // create transform schema -const transformedChirpSchema = transformSchema(propertySchema, [ +const transformedChirpSchema = transformSchema(chirpSchema, [ Transforms.FilterRootFields((operation: string, rootField: string) => ['Query.chirpById'].includes(`${operation}.${rootField}`), ), @@ -235,23 +234,23 @@ mergeSchemas({ authorSchema, linkTypeDefs, ], - resolvers: mergeInfo => ({ + resolvers: { User: { chirps: { fragment: `fragment UserFragment on User { id }`, resolve(parent, args, context, info) { const authorId = parent.id; - return mergeInfo.delegateToSchema( - chirpSchema, - 'query', - 'chirpsByAuthorId', - { + return info.mergeInfo.delegateToSchema({ + schema: chirpSchema, + operation: 'query', + fieldName: 'chirpsByAuthorId', + args: { authorId, }, context, info, - transformedChirpSchema.transforms, - ); + transforms: transformedChirpSchema.transforms, + }); }, }, }, @@ -260,20 +259,20 @@ mergeSchemas({ fragment: `fragment ChirpFragment on Chirp { authorId }`, resolve(parent, args, context, info) { const id = parent.authorId; - return mergeInfo.delegateToSchema( - authorSchema, - 'query', - 'userById', - { + return info.mergeInfo.delegateToSchema({ + schema: authorSchema, + operation: 'query', + fieldName: 'userById', + args: { id, }, context, info, - ); + }); }, }, }, - }), + }, }); ``` @@ -326,37 +325,43 @@ resolvers: mergeInfo => ({ property: { fragment: 'fragment BookingFragment on Booking { propertyId }', resolve(parent, args, context, info) { - return mergeInfo.delegateToSchema( - bookingSchema, - 'query', - 'propertyById', - { + return mergeInfo.delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'propertyById', + args: { id: parent.propertyId, }, context, info, - ); + }); }, }, }, }) ``` -#### mergeInfo and delegate +#### mergeInfo and delegateToSchema `mergeInfo` currently is an object with `delegateToSchema` property. It looks like this: ```js type MergeInfo = { - delegateToSchema( - schema: GraphQLSchema, - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, - transforms?: Array, - ) => any, + delegateToSchema(options: IDelegateToSchemaOptions): any; +} + +interface IDelegateToSchemaOptions { + schema: GraphQLSchema; + operation: Operation; + fieldName: string; + args?: { + [key: string]: any; + }; + context: TContext; + info: GraphQLResolveInfo; + transforms?: Array; } ``` `delegateToSchema` allows delegating to any GraphQLSchema, while adding `fragmentReplacement` transforms. It's identical to `delegateToSchema` function otherwise. See [Schema Delegation](./schema-delegation.html) and *Using with transforms* section of this documentation. @@ -364,7 +369,7 @@ type MergeInfo = { #### onTypeConflict -``` +```js type OnTypeConflict = ( left: GraphQLNamedType, right: GraphQLNamedType, From 370e701df413cd24a7c8233dc7204272ff8acd78 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Thu, 19 Apr 2018 12:13:52 +0300 Subject: [PATCH 45/57] Added Changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b548f8ade1..a2b13265694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change log +### v3.0.0 (prerelease) + +* Full rewrite of internals of mergeSchemas and delegateToSchema. Multiple bugs fixed +* `delegateToSchema` is now a public API [Docs](https://www.apollographql.com/docs/graphql-tools/schema-delegation.html) +* `delegateToSchema` now accepts an object. List of arguments is a deprecated API. +* `delegateToSchema` no longer accepts `fragmentReplacements`, instead accepting transforms +* `info.mergeInfo.delegateToSchema` is now a preferred delegation API, `info.mergeInfo.delegate` is deprecated +* Using `mergeInfo` closure in resolvers of `mergeSchemas` is deprecated +* Add schema transforms [Docs](https://www.apollographql.com/docs/graphql-tools/schema-transforms.html) +* Add `transformSchema`, `visitSchema`, `visitObject` as utility functions for + transforms + ### vNEXT ### v2.24.0 From 62972c3d04d70ace52d0ff0b2f0662576de74471 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Fri, 20 Apr 2018 10:28:53 +0300 Subject: [PATCH 46/57] Update schema-transforms.md --- docs/source/schema-transforms.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index 5c3f4eb1be4..91685d862e4 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -64,7 +64,7 @@ Lastly, we need a result transform. This only comes into play if we request `__t

API

-

mergeSchemas

+

Transform

```js type Transform = { From 341a4ba79b7587d326b916beab2630ccd4d1885a Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Fri, 20 Apr 2018 12:54:08 +0300 Subject: [PATCH 47/57] Move everything to be a class --- docs/source/schema-stitching.md | 6 +- docs/source/schema-transforms.md | 9 +- src/Interfaces.ts | 9 +- src/stitching/delegateToSchema.ts | 8 +- src/stitching/mergeSchemas.ts | 62 +++----- src/test/testAlternateMergeSchemas.ts | 18 +-- src/test/testTransforms.ts | 8 +- src/transforms/AddArgumentsAsVariables.ts | 45 +++--- src/transforms/AddTypenameToAbstract.ts | 32 +++-- src/transforms/CheckResultAndHandleErrors.ts | 21 +-- src/transforms/ExpandAbstractTypes.ts | 46 +++--- src/transforms/FilterRootFields.ts | 38 +++-- src/transforms/FilterToSchema.ts | 30 ++-- src/transforms/FilterTypes.ts | 34 ++--- src/transforms/RenameRootFields.ts | 50 ++++--- src/transforms/RenameTypes.ts | 141 ++++++++++--------- src/transforms/ReplaceFieldWithFragment.ts | 84 ++++++++--- src/transforms/TransformRootFields.ts | 62 ++++---- src/transforms/index.ts | 2 +- 19 files changed, 381 insertions(+), 324 deletions(-) diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 635ec547a72..69275b2cf0f 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -217,11 +217,11 @@ addMockFunctionsToSchema({ schema: chirpSchema }); // create transform schema const transformedChirpSchema = transformSchema(chirpSchema, [ - Transforms.FilterRootFields((operation: string, rootField: string) => + new Transforms.FilterRootFields((operation: string, rootField: string) => ['Query.chirpById'].includes(`${operation}.${rootField}`), ), - Transforms.RenameTypes((name: string) => `Chirp_${name}`), - Transforms.RenameRootFields((name: string) => `Chirp_${name}`), + new Transforms.RenameTypes((name: string) => `Chirp_${name}`), + new Transforms.RenameRootFields((name: string) => `Chirp_${name}`), ]); ``` diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index 91685d862e4..e774da43a6e 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -64,10 +64,10 @@ Lastly, we need a result transform. This only comes into play if we request `__t

API

-

Transform

+

Transform

```js -type Transform = { +interface Transform = { transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; transformRequest?: (request: Request) => Request; transformResult?: (result: Result) => Result; @@ -157,7 +157,7 @@ A helper function to do a depth-first traversal of a nested object, such as `dat

Built-in transforms

-Built-in transforms are all functions returning a `Transform`. +Built-in transforms are all classes implementing a `Transform` interface. ### Modifying types @@ -208,7 +208,7 @@ type RootFilter = ( * `RenameRootFields(renamer)` - rename root fields, by applying `renamer` to their names. ```js -function RenameRootFields( +RenameRootFields( renamer: ( operation: 'Query' | 'Mutation' | 'Subscription', name: string, @@ -235,3 +235,4 @@ Those transforms are automatically added to transform list by `delegateToSchema` * `AddTypenameToAbstract` - add `__typename` to all abstract types in the document * `FilterToSchema` - given a schema and document, remove all fields, variables and fragments for the types that don't exist in that schema * `CheckResultAndHandleErrors` - given a result from a subschema, propagate errors so that they match correct subfield. Also provide correct key if the aliases are used. +* `ExpandAbstractTypes` - given a transformed schema and a subschema, expand all abstract types that aren't implemented into subschema into their implementations diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 2c19ea67ae1..be452231fec 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -33,6 +33,7 @@ export interface IAddResolveFunctionsToSchemaOptions { } export interface IResolverOptions { + fragment?: string; resolve?: IFieldResolver; subscribe?: IFieldResolver; __resolveType?: GraphQLTypeResolver; @@ -45,9 +46,7 @@ export type Transform = { transformResult?: (result: Result) => Result; }; -export interface IDelegateToSchemaOptions< - TContext = { [key: string]: any }, -> { +export interface IDelegateToSchemaOptions { schema: GraphQLSchema; operation: Operation; fieldName: string; @@ -66,9 +65,7 @@ export type MergeInfo = { info: GraphQLResolveInfo, transforms?: Array, ) => any; - delegateToSchema( - options: IDelegateToSchemaOptions - ): any, + delegateToSchema(options: IDelegateToSchemaOptions): any; }; export type IFieldResolver = ( diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 123830acc54..dc9cf3b3a0e 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -43,10 +43,10 @@ export default async function delegateToSchema( const transforms = [ ...(options.transforms || []), - AddArgumentsAsVariables(options.schema, args), - FilterToSchema(options.schema), - AddTypenameToAbstract(options.schema), - CheckResultAndHandleErrors(info, options.fieldName), + new AddArgumentsAsVariables(options.schema, args), + new FilterToSchema(options.schema), + new AddTypenameToAbstract(options.schema), + new CheckResultAndHandleErrors(info, options.fieldName), ]; const processedRequest = applyRequestTransforms(rawRequest, transforms); diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 1f2189615c3..ec1c9c4c94c 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -9,9 +9,6 @@ import { GraphQLScalarType, GraphQLSchema, GraphQLString, - InlineFragmentNode, - Kind, - OperationDefinitionNode, extendSchema, getNamedType, isNamedType, @@ -92,7 +89,10 @@ function mergeSchemasImplementation({ const typeCandidates: { [name: string]: Array } = {}; const types: { [name: string]: GraphQLNamedType } = {}; const extensions: Array = []; - const fragments = {}; + const fragments: Array<{ + field: string; + fragment: string; + }> = []; if (!visitType) { visitType = defaultVisitType; @@ -268,10 +268,10 @@ function mergeSchemasImplementation({ Object.keys(type).forEach(fieldName => { const field = type[fieldName]; if (field.fragment) { - const parsedFragment = parseFragmentToInlineFragment(field.fragment); - const actualTypeName = parsedFragment.typeCondition.name.value; - fragments[actualTypeName] = fragments[actualTypeName] || {}; - fragments[actualTypeName][fieldName] = parsedFragment; + fragments.push({ + field: fieldName, + fragment: field.fragment, + }); } }); }); @@ -303,9 +303,10 @@ function mergeSchemasImplementation({ function createMergeInfo( allSchemas: Array, - fragmentReplacements: { - [name: string]: { [fieldName: string]: InlineFragmentNode }; - }, + fragments: Array<{ + field: string; + fragment: string; + }>, ): MergeInfo { return { delegate( @@ -321,13 +322,13 @@ function createMergeInfo( 'Use `mergeInfo.delegateToSchema and pass explicit schema instances.', ); const schema = guessSchemaByRootField(allSchemas, operation, fieldName); - const expandTransforms = Transforms.ExpandAbstractTypes( + const expandTransforms = new Transforms.ExpandAbstractTypes( info.schema, schema, ); - const fragmentTransform = Transforms.ReplaceFieldWithFragment( + const fragmentTransform = new Transforms.ReplaceFieldWithFragment( schema, - fragmentReplacements, + fragments, ); return delegateToSchema({ schema, @@ -349,11 +350,11 @@ function createMergeInfo( ...options, transforms: [ ...(options.transforms || []), - Transforms.ExpandAbstractTypes(options.info.schema, options.schema), - Transforms.ReplaceFieldWithFragment( + new Transforms.ExpandAbstractTypes( + options.info.schema, options.schema, - fragmentReplacements, ), + new Transforms.ReplaceFieldWithFragment(options.schema, fragments), ], }); }, @@ -427,33 +428,6 @@ function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { }); } -function parseFragmentToInlineFragment( - definitions: string, -): InlineFragmentNode { - if (definitions.trim().startsWith('fragment')) { - const document = parse(definitions); - for (const definition of document.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - return { - kind: Kind.INLINE_FRAGMENT, - typeCondition: definition.typeCondition, - selectionSet: definition.selectionSet, - }; - } - } - } - - const query = parse(`{${definitions}}`) - .definitions[0] as OperationDefinitionNode; - for (const selection of query.selectionSet.selections) { - if (selection.kind === Kind.INLINE_FRAGMENT) { - return selection; - } - } - - throw new Error('Could not parse fragment'); -} - function addTypeCandidate( typeCandidates: { [name: string]: Array }, name: string, diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index f964773284e..5852fb9afbd 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -56,18 +56,20 @@ describe('merge schemas through transforms', () => { before(async () => { // namespace and strip schemas const transformedPropertySchema = transformSchema(propertySchema, [ - Transforms.FilterRootFields((operation: string, rootField: string) => - 'Query.properties' === `${operation}.${rootField}`, + new Transforms.FilterRootFields( + (operation: string, rootField: string) => + 'Query.properties' === `${operation}.${rootField}`, ), - Transforms.RenameTypes((name: string) => `Properties_${name}`), - Transforms.RenameRootFields((name: string) => `Properties_${name}`), + new Transforms.RenameTypes((name: string) => `Properties_${name}`), + new Transforms.RenameRootFields((name: string) => `Properties_${name}`), ]); const transformedBookingSchema = transformSchema(bookingSchema, [ - Transforms.FilterRootFields((operation: string, rootField: string) => - 'Query.bookings' === `${operation}.${rootField}`, + new Transforms.FilterRootFields( + (operation: string, rootField: string) => + 'Query.bookings' === `${operation}.${rootField}`, ), - Transforms.RenameTypes((name: string) => `Bookings_${name}`), - Transforms.RenameRootFields( + new Transforms.RenameTypes((name: string) => `Bookings_${name}`), + new Transforms.RenameRootFields( (operation: string, name: string) => `Bookings_${name}`, ), ]); diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index 3133aad490b..d9fe280c070 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -10,7 +10,7 @@ describe('transforms', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - Transforms.RenameTypes( + new Transforms.RenameTypes( (name: string) => ({ Property: 'House', @@ -72,7 +72,7 @@ describe('transforms', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - Transforms.RenameTypes((name: string) => `Property_${name}`), + new Transforms.RenameTypes((name: string) => `Property_${name}`), ]; schema = transformSchema(propertySchema, transforms); }); @@ -124,8 +124,8 @@ describe('transforms', () => { before(() => { const typeNames = ['ID', 'String', 'DateTime', 'Query', 'Booking']; const transforms = [ - Transforms.FilterTypes((type: GraphQLNamedType) => - typeNames.indexOf(type.name) >= 0 + new Transforms.FilterTypes( + (type: GraphQLNamedType) => typeNames.indexOf(type.name) >= 0, ), ]; schema = transformSchema(bookingSchema, transforms); diff --git a/src/transforms/AddArgumentsAsVariables.ts b/src/transforms/AddArgumentsAsVariables.ts index 232b416d3c3..b2378ff8b64 100644 --- a/src/transforms/AddArgumentsAsVariables.ts +++ b/src/transforms/AddArgumentsAsVariables.ts @@ -18,27 +18,30 @@ import { import { Request } from '../Interfaces'; import { Transform } from './transforms'; -export default function AddArgumentsAsVariablesTransform( - schema: GraphQLSchema, - args: { [key: string]: any }, -): Transform { - return { - transformRequest(originalRequest: Request): Request { - const { document, newVariables } = addVariablesToRootField( - schema, - originalRequest.document, - args, - ); - const variables = { - ...originalRequest.variables, - ...newVariables, - }; - return { - document, - variables, - }; - }, - }; +export default class AddArgumentsAsVariablesTransform implements Transform { + private schema: GraphQLSchema; + private args: { [key: string]: any }; + + constructor(schema: GraphQLSchema, args: { [key: string]: any }) { + this.schema = schema; + this.args = args; + } + + public transformRequest(originalRequest: Request): Request { + const { document, newVariables } = addVariablesToRootField( + this.schema, + originalRequest.document, + this.args, + ); + const variables = { + ...originalRequest.variables, + ...newVariables, + }; + return { + document, + variables, + }; + } } function addVariablesToRootField( diff --git a/src/transforms/AddTypenameToAbstract.ts b/src/transforms/AddTypenameToAbstract.ts index 5cf46c36e74..ef790062219 100644 --- a/src/transforms/AddTypenameToAbstract.ts +++ b/src/transforms/AddTypenameToAbstract.ts @@ -14,21 +14,23 @@ import { import { Request } from '../Interfaces'; import { Transform } from './transforms'; -export default function AddTypenameToAbstract( - targetSchema: GraphQLSchema, -): Transform { - return { - transformRequest(originalRequest: Request): Request { - const document = addTypenameToAbstract( - targetSchema, - originalRequest.document, - ); - return { - ...originalRequest, - document, - }; - }, - }; +export default class AddTypenameToAbstract implements Transform { + private targetSchema: GraphQLSchema; + + constructor(targetSchema: GraphQLSchema) { + this.targetSchema = targetSchema; + } + + public transformRequest(originalRequest: Request): Request { + const document = addTypenameToAbstract( + this.targetSchema, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + } } function addTypenameToAbstract( diff --git a/src/transforms/CheckResultAndHandleErrors.ts b/src/transforms/CheckResultAndHandleErrors.ts index c27367e817d..73532674825 100644 --- a/src/transforms/CheckResultAndHandleErrors.ts +++ b/src/transforms/CheckResultAndHandleErrors.ts @@ -2,13 +2,16 @@ import { GraphQLResolveInfo } from 'graphql'; import { checkResultAndHandleErrors } from '../stitching/errors'; import { Transform } from './transforms'; -export default function CheckResultAndHandleErrors( - info: GraphQLResolveInfo, - fieldName?: string, -): Transform { - return { - transformResult(result: any): any { - return checkResultAndHandleErrors(result, info, fieldName); - }, - }; +export default class CheckResultAndHandleErrors implements Transform { + private info: GraphQLResolveInfo; + private fieldName?: string; + + constructor(info: GraphQLResolveInfo, fieldName?: string) { + this.info = info; + this.fieldName = fieldName; + } + + public transformResult(result: any): any { + return checkResultAndHandleErrors(result, this.info, this.fieldName); + } } diff --git a/src/transforms/ExpandAbstractTypes.ts b/src/transforms/ExpandAbstractTypes.ts index fec338fc9c9..0038d0f8412 100644 --- a/src/transforms/ExpandAbstractTypes.ts +++ b/src/transforms/ExpandAbstractTypes.ts @@ -18,29 +18,29 @@ import { Transform, Request } from '../Interfaces'; type TypeMapping = { [key: string]: Array }; -export default function ExpandAbstractTypes( - transformedSchema: GraphQLSchema, - targetSchema: GraphQLSchema, -): Transform { - const mapping: TypeMapping = extractPossibleTypes( - transformedSchema, - targetSchema, - ); - const reverseMapping: TypeMapping = flipMapping(mapping); - return { - transformRequest(originalRequest: Request): Request { - const document = expandAbstractTypes( - targetSchema, - mapping, - reverseMapping, - originalRequest.document, - ); - return { - ...originalRequest, - document, - }; - }, - }; +export default class ExpandAbstractTypes implements Transform { + private targetSchema: GraphQLSchema; + private mapping: TypeMapping; + private reverseMapping: TypeMapping; + + constructor(transformedSchema: GraphQLSchema, targetSchema: GraphQLSchema) { + this.targetSchema = targetSchema; + this.mapping = extractPossibleTypes(transformedSchema, targetSchema); + this.reverseMapping = flipMapping(this.mapping); + } + + public transformRequest(originalRequest: Request): Request { + const document = expandAbstractTypes( + this.targetSchema, + this.mapping, + this.reverseMapping, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + } } function extractPossibleTypes( diff --git a/src/transforms/FilterRootFields.ts b/src/transforms/FilterRootFields.ts index 244d7c8a775..47eebaf8636 100644 --- a/src/transforms/FilterRootFields.ts +++ b/src/transforms/FilterRootFields.ts @@ -1,4 +1,4 @@ -import { GraphQLField } from 'graphql'; +import { GraphQLField, GraphQLSchema } from 'graphql'; import { Transform } from './transforms'; import TransformRootFields from './TransformRootFields'; @@ -8,18 +8,26 @@ export type RootFilter = ( field: GraphQLField, ) => Boolean; -export default function FilterRootFields(filter: RootFilter): Transform { - return TransformRootFields( - ( - operation: 'Query' | 'Mutation' | 'Subscription', - fieldName: string, - field: GraphQLField, - ) => { - if (filter(operation, fieldName, field)) { - return undefined; - } else { - return null; - } - }, - ); +export default class FilterRootFields implements Transform { + private transformer: TransformRootFields; + + constructor(filter: RootFilter) { + this.transformer = new TransformRootFields( + ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, + ) => { + if (filter(operation, fieldName, field)) { + return undefined; + } else { + return null; + } + }, + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } } diff --git a/src/transforms/FilterToSchema.ts b/src/transforms/FilterToSchema.ts index f9c65f37576..fd057a05e08 100644 --- a/src/transforms/FilterToSchema.ts +++ b/src/transforms/FilterToSchema.ts @@ -25,19 +25,23 @@ import { Request } from '../Interfaces'; import implementsAbstractType from '../implementsAbstractType'; import { Transform } from './transforms'; -export default function FilterToSchema(targetSchema: GraphQLSchema): Transform { - return { - transformRequest(originalRequest: Request): Request { - const document = filterDocumentToSchema( - targetSchema, - originalRequest.document, - ); - return { - ...originalRequest, - document, - }; - }, - }; +export default class FilterToSchema implements Transform { + private targetSchema: GraphQLSchema; + + constructor(targetSchema: GraphQLSchema) { + this.targetSchema = targetSchema; + } + + public transformRequest(originalRequest: Request): Request { + const document = filterDocumentToSchema( + this.targetSchema, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + } } function filterDocumentToSchema( diff --git a/src/transforms/FilterTypes.ts b/src/transforms/FilterTypes.ts index b91ae7f1410..ea8d24141a6 100644 --- a/src/transforms/FilterTypes.ts +++ b/src/transforms/FilterTypes.ts @@ -4,20 +4,22 @@ import { GraphQLSchema, GraphQLNamedType } from 'graphql'; import { Transform } from '../transforms/transforms'; import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; -export default function FilterTypes( - filter: (type: GraphQLNamedType) => Boolean, -): Transform { - return { - transformSchema(schema: GraphQLSchema): GraphQLSchema { - return visitSchema(schema, { - [VisitSchemaKind.TYPE](type: GraphQLNamedType): null | undefined { - if (filter(type)) { - return undefined; - } else { - return null; - } - }, - }); - }, - }; +export default class FilterTypes implements Transform { + private filter: (type: GraphQLNamedType) => Boolean; + + constructor(filter: (type: GraphQLNamedType) => Boolean) { + this.filter = filter; + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + return visitSchema(schema, { + [VisitSchemaKind.TYPE]: (type: GraphQLNamedType) => { + if (this.filter(type)) { + return undefined; + } else { + return null; + } + }, + }); + } } diff --git a/src/transforms/RenameRootFields.ts b/src/transforms/RenameRootFields.ts index f31d2529b83..30fdbf4fe67 100644 --- a/src/transforms/RenameRootFields.ts +++ b/src/transforms/RenameRootFields.ts @@ -1,4 +1,4 @@ -import { GraphQLNamedType, GraphQLField } from 'graphql'; +import { GraphQLNamedType, GraphQLField, GraphQLSchema } from 'graphql'; import { Transform } from './transforms'; import { createResolveType, @@ -6,26 +6,34 @@ import { } from '../stitching/schemaRecreation'; import TransformRootFields from './TransformRootFields'; -export default function RenameRootFields( - renamer: ( - operation: 'Query' | 'Mutation' | 'Subscription', - name: string, - field: GraphQLField, - ) => string, -): Transform { - const resolveType = createResolveType( - (name: string, type: GraphQLNamedType): GraphQLNamedType => type, - ); - return TransformRootFields( - ( +export default class RenameRootFields implements Transform { + private transformer: TransformRootFields; + + constructor( + renamer: ( operation: 'Query' | 'Mutation' | 'Subscription', - fieldName: string, + name: string, field: GraphQLField, - ) => { - return { - name: renamer(operation, fieldName, field), - field: fieldToFieldConfig(field, resolveType, true), - }; - }, - ); + ) => string, + ) { + const resolveType = createResolveType( + (name: string, type: GraphQLNamedType): GraphQLNamedType => type, + ); + this.transformer = new TransformRootFields( + ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, + ) => { + return { + name: renamer(operation, fieldName, field), + field: fieldToFieldConfig(field, resolveType, true), + }; + }, + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } } diff --git a/src/transforms/RenameTypes.ts b/src/transforms/RenameTypes.ts index 864c92072ff..8d76501aed3 100644 --- a/src/transforms/RenameTypes.ts +++ b/src/transforms/RenameTypes.ts @@ -13,78 +13,85 @@ import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; import visitObject from '../transforms/visitObject'; export type RenameOptions = { - renameBuiltins: Boolean; - renameScalars: Boolean; + renameBuiltins: boolean; + renameScalars: boolean; }; -export default function RenameTypes( - renamer: (name: string) => string | undefined, - options?: RenameOptions, -): Transform { - const reverseMap = {}; - const { renameBuiltins = false, renameScalars = true } = options || {}; - return { - transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { - return visitSchema(originalSchema, { - [VisitSchemaKind.TYPE]( - type: GraphQLNamedType, - ): GraphQLNamedType | undefined { - if (isSpecifiedScalarType(type) && !renameBuiltins) { - return undefined; - } - if (type instanceof GraphQLScalarType && !renameScalars) { - return undefined; - } - const newName = renamer(type.name); - if (newName && newName !== type.name) { - reverseMap[newName] = type.name; - const newType = Object.assign(Object.create(type), type); - newType.name = newName; - return newType; - } - }, +export default class RenameTypes implements Transform { + private renamer: (name: string) => string | undefined; + private reverseMap: { [key: string]: string }; + private renameBuiltins: boolean; + private renameScalars: boolean; - [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { + constructor( + renamer: (name: string) => string | undefined, + options?: RenameOptions, + ) { + this.renamer = renamer; + this.reverseMap = {}; + const { renameBuiltins = false, renameScalars = true } = options || {}; + this.renameBuiltins = renameBuiltins; + this.renameScalars = renameScalars; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return visitSchema(originalSchema, { + [VisitSchemaKind.TYPE]: (type: GraphQLNamedType) => { + if (isSpecifiedScalarType(type) && !this.renameBuiltins) { return undefined; - }, - }); - }, + } + if (type instanceof GraphQLScalarType && !this.renameScalars) { + return undefined; + } + const newName = this.renamer(type.name); + if (newName && newName !== type.name) { + this.reverseMap[newName] = type.name; + const newType = Object.assign(Object.create(type), type); + newType.name = newName; + return newType; + } + }, - transformRequest(originalRequest: Request): Request { - const newDocument = visit(originalRequest.document, { - [Kind.NAMED_TYPE](node: NamedTypeNode): NamedTypeNode | undefined { - const name = node.name.value; - if (name in reverseMap) { - return { - ...node, - name: { - kind: Kind.NAME, - value: reverseMap[name], - }, - }; - } - }, + [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { + return undefined; + }, + }); + } + + public transformRequest(originalRequest: Request): Request { + const newDocument = visit(originalRequest.document, { + [Kind.NAMED_TYPE]: (node: NamedTypeNode) => { + const name = node.name.value; + if (name in this.reverseMap) { + return { + ...node, + name: { + kind: Kind.NAME, + value: this.reverseMap[name], + }, + }; + } + }, + }); + return { + document: newDocument, + variables: originalRequest.variables, + }; + } + + public transformResult(result: Result): Result { + if (result.data) { + const newData = visitObject(result.data, (key, value) => { + if (key === '__typename') { + return this.renamer(value); + } }); - return { - document: newDocument, - variables: originalRequest.variables, + const newResult = { + ...result, + data: newData, }; - }, - - transformResult(result: Result): Result { - if (result.data) { - const newData = visitObject(result.data, (key, value) => { - if (key === '__typename') { - return renamer(value); - } - }); - const newResult = { - ...result, - data: newData, - }; - return newResult; - } - return result; - }, - }; + return newResult; + } + return result; + } } diff --git a/src/transforms/ReplaceFieldWithFragment.ts b/src/transforms/ReplaceFieldWithFragment.ts index 8ca1c3075c6..add06a3e98f 100644 --- a/src/transforms/ReplaceFieldWithFragment.ts +++ b/src/transforms/ReplaceFieldWithFragment.ts @@ -6,35 +6,52 @@ import { Kind, SelectionSetNode, TypeInfo, + OperationDefinitionNode, + parse, visit, visitWithTypeInfo, } from 'graphql'; import { Request } from '../Interfaces'; import { Transform } from './transforms'; -export type FieldToFragmentMapping = { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; -}; +export default class ReplaceFieldWithFragment implements Transform { + private targetSchema: GraphQLSchema; + private mapping: FieldToFragmentMapping; -export default function ReplaceFieldWithFragment( - targetSchema: GraphQLSchema, - mapping: FieldToFragmentMapping, -): Transform { - return { - transformRequest(originalRequest: Request): Request { - const document = replaceFieldsWithFragments( - targetSchema, - originalRequest.document, - mapping, - ); - return { - ...originalRequest, - document, - }; - }, - }; + constructor( + targetSchema: GraphQLSchema, + fragments: Array<{ + field: string; + fragment: string; + }>, + ) { + this.targetSchema = targetSchema; + this.mapping = {}; + for (const { field, fragment } of fragments) { + const parsedFragment = parseFragmentToInlineFragment(fragment); + const actualTypeName = parsedFragment.typeCondition.name.value; + this.mapping[actualTypeName] = fragments[actualTypeName] || {}; + this.mapping[actualTypeName][field] = parsedFragment; + } + } + + public transformRequest(originalRequest: Request): Request { + const document = replaceFieldsWithFragments( + this.targetSchema, + originalRequest.document, + this.mapping, + ); + return { + ...originalRequest, + document, + }; + } } +type FieldToFragmentMapping = { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +}; + function replaceFieldsWithFragments( targetSchema: GraphQLSchema, document: DocumentNode, @@ -75,3 +92,30 @@ function replaceFieldsWithFragments( }), ); } + +function parseFragmentToInlineFragment( + definitions: string, +): InlineFragmentNode { + if (definitions.trim().startsWith('fragment')) { + const document = parse(definitions); + for (const definition of document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition: definition.typeCondition, + selectionSet: definition.selectionSet, + }; + } + } + } + + const query = parse(`{${definitions}}`) + .definitions[0] as OperationDefinitionNode; + for (const selection of query.selectionSet.selections) { + if (selection.kind === Kind.INLINE_FRAGMENT) { + return selection; + } + } + + throw new Error('Could not parse fragment'); +} diff --git a/src/transforms/TransformRootFields.ts b/src/transforms/TransformRootFields.ts index b005ae604fa..cc47ef3adb4 100644 --- a/src/transforms/TransformRootFields.ts +++ b/src/transforms/TransformRootFields.ts @@ -23,36 +23,38 @@ export type RootTransformer = ( | null | undefined; -export default function TransformRootFields( - transform: RootTransformer, -): Transform { - return { - transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { - return visitSchema(originalSchema, { - [VisitSchemaKind.QUERY](type: GraphQLObjectType) { - return transformFields( - type, - (fieldName: string, field: GraphQLField) => - transform('Query', fieldName, field), - ); - }, - [VisitSchemaKind.MUTATION](type: GraphQLObjectType) { - return transformFields( - type, - (fieldName: string, field: GraphQLField) => - transform('Mutation', fieldName, field), - ); - }, - [VisitSchemaKind.SUBSCRIPTION](type: GraphQLObjectType) { - return transformFields( - type, - (fieldName: string, field: GraphQLField) => - transform('Subscription', fieldName, field), - ); - }, - }); - }, - }; +export default class TransformRootFields implements Transform { + private transform: RootTransformer; + + constructor(transform: RootTransformer) { + this.transform = transform; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return visitSchema(originalSchema, { + [VisitSchemaKind.QUERY]: (type: GraphQLObjectType) => { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + this.transform('Query', fieldName, field), + ); + }, + [VisitSchemaKind.MUTATION]: (type: GraphQLObjectType) => { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + this.transform('Mutation', fieldName, field), + ); + }, + [VisitSchemaKind.SUBSCRIPTION]: (type: GraphQLObjectType) => { + return transformFields( + type, + (fieldName: string, field: GraphQLField) => + this.transform('Subscription', fieldName, field), + ); + }, + }); + } } function transformFields( diff --git a/src/transforms/index.ts b/src/transforms/index.ts index 952d3da818c..1af4f76bd2c 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -16,7 +16,7 @@ import FilterRootFields from './FilterRootFields'; import ExpandAbstractTypes from './ExpandAbstractTypes'; export const Transforms: { - [name: string]: (...args: any[]) => Transform + [name: string]: { new (...args: Array): Transform }; } = { AddArgumentsAsVariables, CheckResultAndHandleErrors, From f0219e0a475638b3841363053425bbaaa7ecb8f4 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Fri, 20 Apr 2018 13:11:43 +0300 Subject: [PATCH 48/57] Make delegate to schema backwards compatible --- src/stitching/delegateToSchema.ts | 59 ++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index dc9cf3b3a0e..2ac90c340d1 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -11,8 +11,17 @@ import { execute, validate, VariableDefinitionNode, + print, + GraphQLResolveInfo, + InlineFragmentNode, + GraphQLSchema, } from 'graphql'; -import { Operation, Request, IDelegateToSchemaOptions } from '../Interfaces'; +import { + Operation, + Request, + IDelegateToSchemaOptions, + Transform, +} from '../Interfaces'; import { applyRequestTransforms, applyResultTransforms, @@ -21,8 +30,56 @@ import AddArgumentsAsVariables from '../transforms/AddArgumentsAsVariables'; import FilterToSchema from '../transforms/FilterToSchema'; import AddTypenameToAbstract from '../transforms/AddTypenameToAbstract'; import CheckResultAndHandleErrors from '../transforms/CheckResultAndHandleErrors'; +import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; export default async function delegateToSchema( + options: IDelegateToSchemaOptions | GraphQLSchema, + ...args: Array +): Promise; +export default async function delegateToSchema( + options: IDelegateToSchemaOptions | GraphQLSchema, + fragmentReplacements: { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; + }, + operation: 'query' | 'mutation' | 'subscription', + fieldName: string, + args: { [key: string]: any }, + context: { [key: string]: any }, + info: GraphQLResolveInfo, + transforms?: Array, +): Promise { + if (options instanceof GraphQLSchema) { + const schema = options; + console.warn( + 'Argument list is a deprecated. Pass object of parameters ' + + 'to delegate to schema', + ); + const fragments: Array<{ field: string; fragment: string }> = []; + Object.keys(fragmentReplacements).forEach(typeName => { + const typeFragments = fragmentReplacements[typeName]; + Object.keys(typeFragments).forEach(field => { + fragments.push({ field, fragment: print(typeFragments[field]) }); + }); + }); + const newOptions: IDelegateToSchemaOptions = { + schema, + operation, + fieldName, + args, + context, + info, + transforms: [ + new ReplaceFieldWithFragment(schema, fragments), + ...(transforms || []), + ], + }; + return delegateToSchemaImplementation(newOptions); + } else { + return delegateToSchemaImplementation(options); + } +} + +async function delegateToSchemaImplementation( options: IDelegateToSchemaOptions, ): Promise { const { info, args = {} } = options; From 11165627a8ca1c22d36bbc4794cb00d28de7e388 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 20 Apr 2018 12:01:54 -0400 Subject: [PATCH 49/57] Minor tweaks to schema-delegation.md. --- docs/source/schema-delegation.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md index 24843990c7b..bd7f6fec8b2 100644 --- a/docs/source/schema-delegation.md +++ b/docs/source/schema-delegation.md @@ -123,7 +123,7 @@ A subschema to delegate to. #### operation: 'query' | 'mutation' | 'subscription' -An operation to use during the delegation. +The operation type to use during the delegation. #### fieldName: string @@ -185,11 +185,13 @@ GraphQL context that is going to be past to subschema execution or subsciption c #### info: GraphQLResolveInfo -GraphQL resolve info of the current resolver. Used to get the query that starts at the current resolver +GraphQL resolve info of the current resolver. Provides access to the subquery that starts at the current resolver. + +Also provides the `info.mergeInfo.delegateToSchema` function discussed above. #### transforms: Array -[Transforms](./transforms.html) to apply to the query and results. Should be the same transforms that were used to transform the schema, if any. One can use `transformedSchema.transforms` to retrieve transforms. +[Transforms](./transforms.html) to apply to the query and results. Should be the same transforms that were used to transform the schema, if any. After transformation, `transformedSchema.transforms` contains the transforms that were applied.

Additional considerations

From e3634d6bf4589b4b53de36cdeafe57b5a03764a4 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 20 Apr 2018 12:02:32 -0400 Subject: [PATCH 50/57] Editing pass over schema-stitching.md. --- docs/source/schema-stitching.md | 107 ++++++++++++++++---------------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 69275b2cf0f..4a1b7b8b98f 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -3,21 +3,21 @@ title: Schema stitching description: Combining multiple GraphQL APIs into one --- -Schema stitching is the ability to create a single GraphQL schema from multiple underlying GraphQL APIs. +Schema stitching is the process of creating a single GraphQL schema from multiple underlying GraphQL APIs. -One of the main benefits of GraphQL is that you can query all of your data as part of one schema, and get everything you need in one request. But as your schema grows, it might become cumbersome to manage it all as one codebase, and it starts to make sense to split it into different modules. You may also want to decompose your schema into separate microservices, which can be developed and deployed independently. +One of the main benefits of GraphQL is that we can query all of our data as part of one schema, and get everything we need in one request. But as the schema grows, it might become cumbersome to manage it all as one codebase, and it starts to make sense to split it into different modules. We may also want to decompose your schema into separate microservices, which can be developed and deployed independently. -In both cases, you use `mergeSchemas` to combine multiple GraphQL schemas together and produce a merged schema that knows how to delegate parts of the query to the relevant subschemas. These subschemas can be either local to the server, or running on a remote server. They can even be services offered by 3rd parties, allowing you to connect to external data and create mashups. +In both cases, we use `mergeSchemas` to combine multiple GraphQL schemas together and produce a merged schema that knows how to delegate parts of the query to the relevant subschemas. These subschemas can be either local to the server, or running on a remote server. They can even be services offered by 3rd parties, allowing us to connect to external data and create mashups.

Working with remote schemas

-In order to merge with a remote schema, you should first use [makeRemoteExecutableSchema](./remote-schemas.html) to create a local proxy for the schema that knows how to call the remote endpoint. You can then merge with that proxy the same way you would merge with a locally implemented schema. +In order to merge with a remote schema, we first call [makeRemoteExecutableSchema](./remote-schemas.html) to create a local proxy for the schema that knows how to call the remote endpoint. We then merge that local proxy schema the same way we would merge any other locally implemented schema.

Basic example

In this example we'll stitch together two very simple schemas. It doesn't matter whether these are local or proxies created with `makeRemoteExecutableSchema`, because the merging itself would be the same. -In this case, we're dealing with two schemas that implement a system with authors and "chirps" - small snippets of text that they can post. +In this case, we're dealing with two schemas that implement a system with users and "chirps"—small snippets of text that users can post. ```js import { @@ -26,9 +26,9 @@ import { mergeSchemas, } from 'graphql-tools'; -// Mocked chirp schema; we don't want to worry about the schema -// implementation right now since we're just demonstrating -// schema stitching +// Mocked chirp schema +// We don't worry about the schema implementation right now since we're just +// demonstrating schema stitching. const chirpSchema = makeExecutableSchema({ typeDefs: ` type Chirp { @@ -60,7 +60,6 @@ const authorSchema = makeExecutableSchema({ ` }); -// This function call adds the mocks to your schema! addMockFunctionsToSchema({ schema: authorSchema }); export const schema = mergeSchemas({ @@ -73,7 +72,7 @@ export const schema = mergeSchemas({ [Run the above example on Launchpad.](https://launchpad.graphql.com/1nkk8vqj9) -This gives you a new schema with the root fields on `Query` from both schemas: +This gives us a new schema with the root fields on `Query` from both schemas (along with the `User` and `Chirp` types): ```graphql type Query { @@ -83,13 +82,13 @@ type Query { } ``` -That means you now have a single schema that allows you to ask for `userById` and `chirpsByAuthorId` in one query for example. +We now have a single schema that supports asking for `userById` and `chirpsByAuthorId` in the same query!

Adding resolvers between schemas

-Proxying the root fields is a great start, but many cases however you'll want to add the ability to navigate from one schema to another. In this example, you might want to be able to get from a particular author to their chirps, or from a chirp to its author. This is more than a convenience once you move beyond querying for objects by a specific id. If you want to get the authors for the `latestChirps` for example, you have no way of knowing the `authorId`s in advance, so you wouldn't be able to get the authors in the same query. +Combining existing root fields is a great start, but in practice we will often want to introduce additional fields for working with the relationships between types that came from different subschemas. For example, we might want to go from a particular user to their chirps, or from a chirp to its author. Or we might want to query a `latestChirps` field and then get the author of each of those chirps. If the only way to obtain a chirp's author is to call the `userById(id)` root query field with the `authorId` of a given chirp, and we don't know the chirp's `authorId` until we receive the GraphQL response, then we won't be able to obtain the authors as part of the same query. -To add the ability to navigate between types, you need to extend existing types with fields that can take you from one to the other. You can do that the same way you add the other parts of the schema: +To add this ability to navigate between types, we need to _extend_ existing types with new fields that translate between the types: ```js const linkTypeDefs = ` @@ -115,20 +114,18 @@ mergeSchemas({ }); ``` -You won't be able to query `User.chirps` or `Chirp.author` yet however, because the merged schema doesn't have resolvers defined for these fields. We'll have to define our own implementation of these. - -So what should these resolvers look like? +We won't be able to query `User.chirps` or `Chirp.author` yet, however, because we still need to define resolvers for these new fields. -When we resolve `User.chirps` or `Chirp.author`, we want to delegate to the relevant root fields. To get from a user to its chirps for example, we'll want to use the `id` of the user to call `chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call into `userById`. +How should these resolvers be implemented? When we resolve `User.chirps` or `Chirp.author`, we want to _delegate_ to the relevant root fields. To get from a user to the user's chirps, for example, we'll want to use the `id` of the user to call `Query.chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call the existing `Query.userById` field. -Resolvers specified as part of `mergeSchema` have access to a `delegateToSchema` function that allows you to delegate to subschemas. +Resolvers for fields in schemas created by `mergeSchema` have access to a handy `delegateToSchema` function (exposed via `info.mergeInfo.delegateToSchema`) that allows forwarding parts of queries (or even whole new queries) to one of the subschemas that was passed to `mergeSchemas`. -In order to delegate to these root fields, we'll need to make sure we've actually requested the `id` of the user or the `authorId` of the chirp. To avoid forcing users to add these to their queries manually, resolvers on a merged schema can define a fragment that specifies the required fields, and these will be added to the query automatically. +In order to delegate to these root fields, we'll need to make sure we've actually requested the `id` of the user or the `authorId` of the chirp. To avoid forcing users to add these fields to their queries manually, resolvers on a merged schema can define a `fragment` property that specifies the required fields, and they will be added to the query automatically. -A complete implementation of schema stitching for these schemas would look like this: +A complete implementation of schema stitching for these schemas might look like this: ```js -mergeSchemas({ +const mergedSchema = mergeSchemas({ schemas: [ chirpSchema, authorSchema, @@ -138,14 +135,13 @@ mergeSchemas({ User: { chirps: { fragment: `fragment UserFragment on User { id }`, - resolve(parent, args, context, info) { - const authorId = parent.id; + resolve(user, args, context, info) { return info.mergeInfo.delegateToSchema({ schema: chirpSchema, operation: 'query', fieldName: 'chirpsByAuthorId', args: { - authorId, + authorId: user.id, }, context, info, @@ -156,14 +152,13 @@ mergeSchemas({ Chirp: { author: { fragment: `fragment ChirpFragment on Chirp { authorId }`, - resolve(parent, args, context, info) { - const id = parent.authorId; + resolve(chirp, args, context, info) { return info.mergeInfo.delegateToSchema({ schema: authorSchema, operation: 'query', fieldName: 'userById', args: { - id, + id: chirp.authorId, }, context, info, @@ -179,13 +174,13 @@ mergeSchemas({

Using with Transforms

-Often, when creating gateways, one might want to modify one of the schemas. The most common tasks include renaming some of the types, filter or removing some of the root fields. By using [transforms](./schema-transforms) with schema stitching, one can do it without much manual work. +Often, when creating a GraphQL gateway that combines multiple existing schemas, we might want to modify one of the schemas. The most common tasks include renaming some of the types, and filtering the root fields. By using [transforms](./schema-transforms) with schema stitching, we can easily tweak the subschemas before merging them together. -While normally, one delegates directly to the schema that is merged, when schemas are transformed, one often need to delegate to original, untransformed schema. For example, even if some root fields are modified, it's often required to still use those root fields inside of the resolvers, for example for links. +Before, when we were simply merging schemas without first transforming them, we would typically delegate directly to one of the merged schemas. Once we add transforms to the mix, there are times when we want to delegate to fields of the new, transformed schemas, and other times when we want to delegate to the original, untransformed schemas. -In this example, we'll namespace `Chirp` schema and remove `chirpsByAuthorId` from it, by using transforms built-in into `graphql-tools`. +For example, suppose we transform the `chirpSchema` by removing the `chirpsByAuthorId` field and add a `Chirp_` prefix to all types and field names, in order to make it very clear which types and fields came from `chirpSchema`: -```js +```ts import { makeExecutableSchema, addMockFunctionsToSchema, @@ -217,18 +212,20 @@ addMockFunctionsToSchema({ schema: chirpSchema }); // create transform schema const transformedChirpSchema = transformSchema(chirpSchema, [ - new Transforms.FilterRootFields((operation: string, rootField: string) => - ['Query.chirpById'].includes(`${operation}.${rootField}`), + new Transforms.FilterRootFields( + (operation: string, rootField: string) => rootField !== 'chirpsByAuthorId' ), new Transforms.RenameTypes((name: string) => `Chirp_${name}`), new Transforms.RenameRootFields((name: string) => `Chirp_${name}`), ]); ``` -Now we have a schema that has all fields and types prefixed with `Chirp_` and has only `chirpById` root field. Now let's implement the resolvers like in previous example. +Now we have a schema that has all fields and types prefixed with `Chirp_` and has only the `chirpById` root field. Note that the original schema has not been modified, and remains fully functional. We've simply created a new, slightly different schema, which hopefully will be more convenient for merging with our other subschemas. + +Now let's implement the resolvers: ```js -mergeSchemas({ +const mergedSchema = mergeSchemas({ schemas: [ transformedChirpSchema, authorSchema, @@ -238,14 +235,13 @@ mergeSchemas({ User: { chirps: { fragment: `fragment UserFragment on User { id }`, - resolve(parent, args, context, info) { - const authorId = parent.id; + resolve(user, args, context, info) { return info.mergeInfo.delegateToSchema({ schema: chirpSchema, operation: 'query', fieldName: 'chirpsByAuthorId', args: { - authorId, + authorId: user.id, }, context, info, @@ -257,14 +253,13 @@ mergeSchemas({ Chirp_Chirp: { author: { fragment: `fragment ChirpFragment on Chirp { authorId }`, - resolve(parent, args, context, info) { - const id = parent.authorId; + resolve(chirp, args, context, info) { return info.mergeInfo.delegateToSchema({ schema: authorSchema, operation: 'query', fieldName: 'userById', args: { - id, + id: chirp.authorId, }, context, info, @@ -276,7 +271,9 @@ mergeSchemas({ }); ``` -We use `delegateToSchema` to be able to delegate to original schema. This way we can use `chirpsByAuthorId` field that has been filtered out of the transformed schema. +Notice that `resolvers.Chirp_Chirp` has been renamed from just `Chirp`, but `resolvers.Chirp_Chirp.author.fragment` still refers to the original `Chirp` type and `authorId` field, rather than `Chirp_Chirp` and `Chirp_authorId`. + +Also, when we call `info.mergeInfo.delegateToSchema` in the `User.chirps` resolvers, we can delegate to the original `chirpsByAuthorId` field, even though it has been filtered out of the final schema. That's because we're delegating to the original `chirpSchema`, which has not been modified by the transforms.

Complex example

@@ -290,7 +287,7 @@ For a more complicated example involving properties and bookings, with implement

mergeSchemas

-``` +```ts mergeSchemas({ schemas: Array>; resolvers?: Array | IResolvers; @@ -313,14 +310,14 @@ This is the main function that implements schema stitching. Read below for a des #### schemas -`schemas` is an array of schemas. Schemas can be `GraphQLSchema` objects, strings or list of GraphQL types. Strings can contain type extensions or GraphQL types, they will be added to resulting schema. Note that type extensions are always applied last, while types are used in order of schemas. +`schemas` is an array of `GraphQLSchema` objects, schema strings, or lists of `GraphQLNamedType`s. Strings can contain type extensions or GraphQL types, which will be added to resulting schema. Note that type extensions are always applied last, while types are defined in the order in which they are provided. #### resolvers -`resolvers` accepts resolvers in same format as [makeExecutableSchema](./resolvers.html). It can also take an Array of resolvers. One addition to the resolver format is the possibility to specify a `fragment` for a resolver. `fragment` must be a GraphQL fragment definition, and allows you to specify which fields from the parent schema are required for the resolver to function correctly. +`resolvers` accepts resolvers in same format as [makeExecutableSchema](./resolvers.html). It can also take an Array of resolvers. One addition to the resolver format is the possibility to specify a `fragment` for a resolver. The `fragment` must be a GraphQL fragment definition string, specifying which fields from the parent schema are required for the resolver to function properly. ```js -resolvers: mergeInfo => ({ +resolvers: { Booking: { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', @@ -338,12 +335,12 @@ resolvers: mergeInfo => ({ }, }, }, -}) +} ``` #### mergeInfo and delegateToSchema -`mergeInfo` currently is an object with `delegateToSchema` property. It looks like this: +The `info.mergeInfo` object provides the `delegateToSchema` method: ```js type MergeInfo = { @@ -364,8 +361,8 @@ interface IDelegateToSchemaOptions; } ``` -`delegateToSchema` allows delegating to any GraphQLSchema, while adding `fragmentReplacement` transforms. It's identical to `delegateToSchema` function otherwise. See [Schema Delegation](./schema-delegation.html) and *Using with transforms* section of this documentation. +As described in the documentation above, `info.mergeInfo.delegateToSchema` allows delegating to any `GraphQLSchema` object, optionally applying transforms in the process. See [Schema Delegation](./schema-delegation.html) and the [*Using with transforms*](#using-with-transforms) section of this document. #### onTypeConflict @@ -384,17 +381,17 @@ type OnTypeConflict = ( ) => GraphQLNamedType; ``` -`onTypeConflict` lets you customize type resolving logic. The default logic is to -take the first encountered type of all the types with the same name. This -method allows customization of this behavior, for example by taking another type or merging types together. +The `onTypeConflict` option to `mergeSchemas` allows customization of type resolving logic. + +The default behavior of `mergeSchemas` is to take the first encountered type of all the types with the same name. If there are conflicts, `onTypeConflict` enables explicit selection of the winning type. -For example, taking types from last schemas, instead of first. +For example, here's how we could select the last type among multiple types with the same name: ```js const onTypeConflict = (left, right) => right; ``` -Taking type from the schema that has higher field `version`. +And here's how we might select the type whose schema has the latest `version`: ```js const onTypeConflict = (left, right, info) => { @@ -405,3 +402,5 @@ const onTypeConflict = (left, right, info) => { } } ``` + +When using schema transforms, `onTypeConflict` is often unnecessary, since transforms can be used to prevent conflicts before merging schemas. However, if you're not using schema transforms, `onTypeConflict` can be a quick way to make `mergeSchemas` produce more desirable results. From 193aaa616b4ea7a21334fda1f50bbca909291841 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 20 Apr 2018 12:02:51 -0400 Subject: [PATCH 51/57] Define reusable IResolversParameterType. --- src/Interfaces.ts | 5 +++++ src/stitching/mergeSchemas.ts | 11 +++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Interfaces.ts b/src/Interfaces.ts index be452231fec..647e16a6f53 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -88,6 +88,11 @@ export interface IResolvers { | GraphQLScalarType | IEnumResolver; } +export type IResolversParameter = + | Array IResolvers)> + | IResolvers + | ((mergeInfo: MergeInfo) => IResolvers); + export interface ILogger { log: (message: string | Error) => void; } diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index ec1c9c4c94c..380ec3412ef 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -23,6 +23,7 @@ import { TypeWithResolvers, VisitType, VisitTypeResult, + IResolversParameter, } from '../Interfaces'; import { extractExtensionDefinitions, @@ -58,10 +59,7 @@ export default function mergeSchemas({ }: { schemas: Array>; onTypeConflict?: OnTypeConflict; - resolvers?: - | Array IResolvers)> - | IResolvers - | ((mergeInfo: MergeInfo) => IResolvers); + resolvers?: IResolversParameter; }): GraphQLSchema { let visitType: VisitType = defaultVisitType; if (onTypeConflict) { @@ -80,10 +78,7 @@ function mergeSchemasImplementation({ }: { schemas: Array>; visitType?: VisitType; - resolvers?: - | Array IResolvers)> - | IResolvers - | ((mergeInfo: MergeInfo) => IResolvers); + resolvers?: IResolversParameter; }): GraphQLSchema { const allSchemas: Array = []; const typeCandidates: { [name: string]: Array } = {}; From dc5187f8e3bbbd77fda9d8942d2900ba225120b6 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 20 Apr 2018 12:34:42 -0400 Subject: [PATCH 52/57] Reimplement RenameTypes#transformResult without using visitObject. This implementation returns new objects only if modifications were made, and avoids defining/exporting a generic abstraction for functionality that was only used once. --- src/transforms/RenameTypes.ts | 40 ++++++++++++++++++++++++++--------- src/transforms/visitObject.ts | 40 ----------------------------------- 2 files changed, 30 insertions(+), 50 deletions(-) delete mode 100644 src/transforms/visitObject.ts diff --git a/src/transforms/RenameTypes.ts b/src/transforms/RenameTypes.ts index 8d76501aed3..8e7bd3b80e9 100644 --- a/src/transforms/RenameTypes.ts +++ b/src/transforms/RenameTypes.ts @@ -10,7 +10,6 @@ import isSpecifiedScalarType from '../isSpecifiedScalarType'; import { Request, Result } from '../Interfaces'; import { Transform } from '../transforms/transforms'; import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; -import visitObject from '../transforms/visitObject'; export type RenameOptions = { renameBuiltins: boolean; @@ -81,17 +80,38 @@ export default class RenameTypes implements Transform { public transformResult(result: Result): Result { if (result.data) { - const newData = visitObject(result.data, (key, value) => { - if (key === '__typename') { - return this.renamer(value); + const data = this.renameTypes(result.data, 'data'); + if (data !== result.data) { + return { ...result, data }; + } + } + + return result; + } + + private renameTypes(value: any, name: string) { + if (name === '__typename') { + return this.renamer(value); + } + + if (value && typeof value === 'object') { + const newObject = Object.create(Object.getPrototypeOf(value)); + let returnNewObject = false; + + Object.keys(value).forEach(key => { + const oldChild = value[key]; + const newChild = this.renameTypes(oldChild, key); + newObject[key] = newChild; + if (newChild !== oldChild) { + returnNewObject = true; } }); - const newResult = { - ...result, - data: newData, - }; - return newResult; + + if (returnNewObject) { + return newObject; + } } - return result; + + return value; } } diff --git a/src/transforms/visitObject.ts b/src/transforms/visitObject.ts deleted file mode 100644 index f0efbce6b70..00000000000 --- a/src/transforms/visitObject.ts +++ /dev/null @@ -1,40 +0,0 @@ -// depth-first search of any object. -export type ObjectVisitor = ( - key: string, - value: any, - parents: Array, -) => any | undefined | null; - -export default function visitObject(object: any, visitor: ObjectVisitor) { - return visitObjectImpl(object, visitor, []); -} - -function visitObjectImpl( - object: any, - visitor: ObjectVisitor, - parents: Array, -) { - if (isObject(object)) { - const result = {}; - Object.keys(object).forEach(key => { - const value = object[key]; - const processedValue = visitor(key, value, parents); - let nextValue; - if (typeof processedValue === 'undefined') { - nextValue = value; - } else if (processedValue === null) { - return; - } else { - nextValue = processedValue; - } - result[key] = visitObjectImpl(nextValue, visitor, [key, ...parents]); - }); - return result; - } else { - return object; - } -} - -function isObject(item: any): Boolean { - return item && typeof item === 'object' && !Array.isArray(item); -} From 452586a6b4eb832f3ee4a04904fe5f0eaafb5ada Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 20 Apr 2018 12:37:49 -0400 Subject: [PATCH 53/57] Rearrange and simplify v3.0.0 CHANGELOG.md section. --- CHANGELOG.md | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5653b4fd2b..6dd0b8b2240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,17 @@ ### v3.0.0 (prerelease) -* Full rewrite of internals of mergeSchemas and delegateToSchema. Multiple bugs fixed -* `delegateToSchema` is now a public API [Docs](https://www.apollographql.com/docs/graphql-tools/schema-delegation.html) -* `delegateToSchema` now accepts an object. List of arguments is a deprecated API. -* `delegateToSchema` no longer accepts `fragmentReplacements`, instead accepting transforms -* `info.mergeInfo.delegateToSchema` is now a preferred delegation API, `info.mergeInfo.delegate` is deprecated -* Using `mergeInfo` closure in resolvers of `mergeSchemas` is deprecated -* Add schema transforms [Docs](https://www.apollographql.com/docs/graphql-tools/schema-transforms.html) -* Add `transformSchema`, `visitSchema`, `visitObject` as utility functions for - transforms - -### vNEXT - -* add commentDescription to printSchema call to match other uses [PR #745](https://github.com/apollographql/graphql-tools/pull/745) -* Add `createResolver` option to `makeRemoteExecutableSchema` [PR #734](https://github.com/apollographql/graphql-tools/pull/734) +* Schema transforms and delegation + * Substantial rewrite of internals of `mergeSchemas` and `delegateToSchema` + * A new API for schema transforms has been introduced: [Docs](https://www.apollographql.com/docs/graphql-tools/schema-transforms.html) + * `delegateToSchema` is now a public API [Docs](https://www.apollographql.com/docs/graphql-tools/schema-delegation.html) + * `delegateToSchema` now accepts an object of named parameters; positional arguments are deprecated + * `delegateToSchema` no longer accepts `fragmentReplacements`, instead accepting `transforms` + * `info.mergeInfo.delegateToSchema` is now the preferred delegation API, rather than `info.mergeInfo.delegate` (deprecated) + +* Other changes + * add commentDescription to printSchema call to match other uses [PR #745](https://github.com/apollographql/graphql-tools/pull/745) + * Add `createResolver` option to `makeRemoteExecutableSchema` [PR #734](https://github.com/apollographql/graphql-tools/pull/734) ### v2.24.0 From e5eb6942de95275da26afa586ba3ff707aff88ce Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 20 Apr 2018 13:20:41 -0400 Subject: [PATCH 54/57] Editing pass over schema-transforms.md. Note that I have removed the sections about visitSchema and visitObject, since those utilities are no longer exported by the graphql-tools package. --- docs/source/schema-transforms.md | 162 +++++++++++-------------------- 1 file changed, 58 insertions(+), 104 deletions(-) diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index e774da43a6e..2cdd75f5f1e 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -3,13 +3,25 @@ title: Schema transforms description: Automatically transforming schemas --- -Schema transforms are abstractions that let you specify transformations to the schema in a generic way, while keeping an ability to delegate back to original schema. It's useful when working with remote schemas, when building GraphQL gateways and using schema stitching. +Schema transforms are a tool for making modified copies of `GraphQLSchema` objects, while preserving the possibility of delegating back to original schema. -While it's possible to change the schema and modify resolvers to accomodate the changes, for many things that would require lots of extra work. Transforms allow definining a generic set of functions that handle for yourself. They can be reused between schemas and applications. +Transforms are useful when working with [remote schemas](./remote-schemas.html), building GraphQL gateways that combine multiple schemas, and/or using [schema stitching](./schema-stitching.html) to combine schemas together without conflicts between types or fields. -Transform is a set of at least one of three *transformers* - a schema transformer, a request transformer and result transformer. Schema transformer takes a schema and returns a schema, request transformer modifies GraphQL Document and variables, while result transformer modifies the request. +While it's possible to modify a schema by hand, the manual approach requires a deep understanding of all the relationships between `GraphQLSchema` properties, which makes it error-prone and labor-intensive. Transforms provide a generic abstraction over all those details, which improves code quality and saves time, not only now but also in the future, because transforms are designed to be reused again and again. -While schema delegation can in many cases accomodate to changes in schema, especially the changes that add or remove types or fields to the schema, changes that require mapping new types or fields to old types require additional transforms. For example, let's consider changing the name of the type in a very simple schema. Let's imagine we've defined a function that takes a GraphQLSchema and replaces all instances of type `Test` with `NewTest`. +Each `Transform` may define three different kinds of transform functions: + +```ts +interface Transform = { + transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; + transformRequest?: (request: Request) => Request; + transformResult?: (result: Result) => Result; +}; +``` + +The most commonly used transform function is `transformSchema`. However, some transforms require modifying incoming requests and/or outgoing results as well, especially if `transformSchema` adds or removes types or fields, since such changes require mapping new types/fields to the original types/fields at runtime. + +For example, let's consider changing the name of the type in a simple schema. Imagine we've written a function that takes a `GraphQLSchema` and replaces all instances of type `Test` with `NewTest`. ```graphql # old schema @@ -34,9 +46,11 @@ type Query { } ``` -We want `NewTest` to be automatically mappped to old typed name `Test`. From the first glance, it should actually work fine: +At runtime, we want the `NewTest` type to be automatically mapped to the old `Test` type. -``` +At first glance, it might seem as though most queries work the same way as before: + +```graphql query { returnTest { id @@ -45,11 +59,13 @@ query { } ``` -As contents of the types didn't change, this can easily be delegate to old schema without any changes. However things change, when Fragments and variables come into play. +Since the fields of the type have not changed, delegating to the old schema is relatively easy here. -``` +However, the new name begins to matter more when fragments and variables are used: + +```graphql query { - returnTest } + returnTest { id ... on NewTest { name @@ -58,15 +74,17 @@ query { } ``` -Type `NewTest` doesn't exist on old schema, thus it will be filtered out by delegation. Here a request transform comes into play, where we can define that we want to rename all times we encounter `NewTest` type name to name `Test` in old schema. +Since the `NewTest` type did not exist on old schema, this fragment will not match anything in the old schema, so it will be filtered out during delegation. -Lastly, we need a result transform. This only comes into play if we request `__typename` from the schema, and here we again need to map from `Test` to `NewTest`. +What we need is a `transformRequest` function that knows how to rename any occurrences of `NewTest` to `Test` before delegating to the old schema. + +By the same reasoning, we also need a `transformResult` function, because any results contain a `__typename` field whose value is `Test`, that name needs to be updated to `NewTest` in the final result.

API

Transform

-```js +```ts interface Transform = { transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; transformRequest?: (request: Request) => Request; @@ -86,99 +104,35 @@ type Result = ExecutionResult & {

transformSchema

-Given a `GraphQLSchema` and an array of `Transform`, produce a new schema transformed by them. Also creates delegating resolvers mapping from new schema root fields to old schema root fields. Most of the time, when using transforms, you would be using this. - -

visitSchema

- -```js -enum VisitSchemaKind { - TYPE = 'VisitSchemaKind.TYPE', - SCALAR_TYPE = 'VisitSchemaKind.SCALAR_TYPE', - ENUM_TYPE = 'VisitSchemaKind.ENUM_TYPE', - COMPOSITE_TYPE = 'VisitSchemaKind.COMPOSITE_TYPE', - OBJECT_TYPE = 'VisitSchemaKind.OBJECT_TYPE', - INPUT_OBJECT_TYPE = 'VisitSchemaKind.INPUT_OBJECT_TYPE', - ABSTRACT_TYPE = 'VisitSchemaKind.ABSTRACT_TYPE', - UNION_TYPE = 'VisitSchemaKind.UNION_TYPE', - INTERFACE_TYPE = 'VisitSchemaKind.INTERFACE_TYPE', - ROOT_OBJECT = 'VisitSchemaKind.ROOT_OBJECT', - QUERY = 'VisitSchemaKind.QUERY', - MUTATION = 'VisitSchemaKind.MUTATION', - SUBSCRIPTION = 'VisitSchemaKind.SUBSCRIPTION', -} +Given a `GraphQLSchema` and an array of `Transform` objects, produce a new schema with those transforms applied. -type SchemaVisitor = { [key: VisitSchemaKind]: TypeVisitor }; -type TypeVisitor = ( - type: GraphQLType, - schema: GraphQLSchema, -) => GraphQLNamedType; - -function visitSchema( - schema: GraphQLSchema, - visitor: SchemaVisitor, - stripResolvers?: Boolean, -) => GraphQLSchema; -``` - -A helper function to modify schema, modelled after `visit` function in `graphql-js`. It's often convinient to use this (together with `visit/visitWithTypeInfo` and `visitObject`) to create transforms. Accepts visitor, where keys are the kind of types one want to visit. More specific kinds override less specific ones (so `SUBSCRIPTION`, if present, would be called instead of `ROOT_OBJECT` or `INTERFACE_TYPE`). If `stripResolvers` is passed, all field resolvers would be removed from the schema. When writing transforms, you usually don't need to do that. Example that changes name of all types, but root. - -```js -visitSchema(schema, { - [VisitSchemaKind.TYPE]( - type: GraphQLNamedType, - ): GraphQLNamedType | undefined { - const newName = `Foo_${type.name}`; - if (newName && newName !== type.name) { - const newType = Object.assign(Object.create(type), type); - newType.name = newName; - return newType; - } - }, - - [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { - return undefined; - }, -}); -``` - -

visitObject

- -```js -type ObjectVisitor = ( - key: string, - value: any, - parents: Array, -) => any | undefined | null; - -visitObject(object: any, visitor: ObjectVisitor) => any -``` - -A helper function to do a depth-first traversal of a nested object, such as `data` of GraphQL `ExecutionResult`. If `visitor` returns `undefined`, the branch is kept intact. On `null` branch is removed. Any other results make the branch be replaced with it. Note that `visitObject` will visit a replaced result too. +Delegating resolvers will also be generated to map from new schema root fields to old schema root fields. Often these automatic resolvers are sufficient, so you don't have to implement your own.

Built-in transforms

-Built-in transforms are all classes implementing a `Transform` interface. +Built-in transforms are ready-made classes implementing the `Transform` interface. They are intended to cover many of the most common schema transformation use cases, but they also serve as examples of how to implement transforms for your own needs. ### Modifying types -* `FilterTypes(filter: (type: GraphQLNamedType) => boolean)` - remove all types for which `filter` returns false -* `RenameTypes(renamer, options?)` - rename types by applying `renamer` to each type name. If it returns `undefined`, name isn't changed. Options controls whether built-in types and scalars are renamed. Root objects aren't renamed by this transform +* `FilterTypes(filter: (type: GraphQLNamedType) => boolean)`: Remove all types for which the `filter` function returns `false`. + +* `RenameTypes(renamer, options?)`: Rename types by applying `renamer` to each type name. If `renamer` returns `undefined`, the name will be left unchanged. Options controls whether built-in types and scalars are renamed. Root objects are never renamed by this transform. -```js +```ts RenameTypes( - (name: string) => string | undefined, + (name: string) => string | void, options?: { - renameBuiltins: Boolean, - renameScalars: Boolean - } + renameBuiltins: Boolean; + renameScalars: Boolean; + }, ) ``` ### Modifying root fields -* `TransformRootFields(transformer: RootTransformer)` - given a transformer, abritrarily transform root fields. Transformer can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, a null to remove the field or undefined to do nothing. +* `TransformRootFields(transformer: RootTransformer)`: Given a transformer, abritrarily transform root fields. The `transformer` can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, `null` to remove the field, or `undefined` to leave the field unchanged. -```js +```ts TransformRootFields(transformer: RootTransformer) type RootTransformer = ( @@ -189,25 +143,24 @@ type RootTransformer = ( | GraphQLFieldConfig | { name: string; field: GraphQLFieldConfig } | null - | undefined; + | void; ``` -* `FilterRootFields(filter: RootFilter)` - filter out root fields, for which filter returns false. +* `FilterRootFields(filter: RootFilter)`: Like `FilterTypes`, removes root fields for which the `filter` function returns `false`. -```js +```ts FilterRootFields(filter: RootFilter) type RootFilter = ( operation: 'Query' | 'Mutation' | 'Subscription', fieldName: string, field: GraphQLField, -) => Boolean; - +) => boolean; ``` -* `RenameRootFields(renamer)` - rename root fields, by applying `renamer` to their names. +* `RenameRootFields(renamer)`: Rename root fields, by applying the `renamer` function to their names. -```js +```ts RenameRootFields( renamer: ( operation: 'Query' | 'Mutation' | 'Subscription', @@ -219,9 +172,9 @@ RenameRootFields( ### Other -* `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, mapping: FieldToFragmentMapping)` - replace fields in types defined in mapping by an inline fragment. Used by `mergeSchemas` to resolve `fragment` option. +* `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, mapping: FieldToFragmentMapping)`: Replace the given fields with an inline fragment. Used by `mergeSchemas` to handle the `fragment` option. -```js +```ts type FieldToFragmentMapping = { [typeName: string]: { [fieldName: string]: InlineFragmentNode }; }; @@ -229,10 +182,11 @@ type FieldToFragmentMapping = {

delegateToSchema transforms

-Those transforms are automatically added to transform list by `delegateToSchema`. Useful if you want to build an alternative `delegateToSchema` implementation. +The following transforms are automatically applied by `delegateToSchema` during schema delegation, to translate between new and old types and fields: + +* `AddArgumentsAsVariables`: Given a schema and arguments passed to a root field, make those arguments document variables. +* `FilterToSchema`: Given a schema and document, remove all fields, variables and fragments for types that don't exist in that schema. +* `AddTypenameToAbstract`: Add `__typename` to all abstract types in the document. +* `CheckResultAndHandleErrors`: Given a result from a subschema, propagate errors so that they match the correct subfield. Also provide the correct key if aliases are used. -* `AddArgumentsAsVariables` - given a schema and arguments to root field passed, make those arguments document variables -* `AddTypenameToAbstract` - add `__typename` to all abstract types in the document -* `FilterToSchema` - given a schema and document, remove all fields, variables and fragments for the types that don't exist in that schema -* `CheckResultAndHandleErrors` - given a result from a subschema, propagate errors so that they match correct subfield. Also provide correct key if the aliases are used. -* `ExpandAbstractTypes` - given a transformed schema and a subschema, expand all abstract types that aren't implemented into subschema into their implementations +By passing a custom `transforms` array to `delegateToSchema`, it's possible to run additional transforms before these default transforms, though it is currently not possible to disable the default transforms. From 169e6987eef7e3fd64d90480ec265d9fd132541f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 20 Apr 2018 13:25:27 -0400 Subject: [PATCH 55/57] Rename Boolean (object) type annotations to boolean (primitive). --- src/isEmptyObject.ts | 2 +- src/mergeDeep.ts | 2 +- src/stitching/schemaRecreation.ts | 6 +++--- src/transforms/FilterRootFields.ts | 2 +- src/transforms/FilterToSchema.ts | 5 ++--- src/transforms/FilterTypes.ts | 4 ++-- src/transforms/visitSchema.ts | 2 +- 7 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/isEmptyObject.ts b/src/isEmptyObject.ts index a67d6713160..4533d20ef86 100644 --- a/src/isEmptyObject.ts +++ b/src/isEmptyObject.ts @@ -1,4 +1,4 @@ -export default function isEmptyObject(obj: Object): Boolean { +export default function isEmptyObject(obj: Object): boolean { if (!obj) { return true; } diff --git a/src/mergeDeep.ts b/src/mergeDeep.ts index e970535cce0..599ab656773 100644 --- a/src/mergeDeep.ts +++ b/src/mergeDeep.ts @@ -16,6 +16,6 @@ export default function mergeDeep(target: any, source: any): any { return output; } -function isObject(item: any): Boolean { +function isObject(item: any): boolean { return item && typeof item === 'object' && !Array.isArray(item); } diff --git a/src/stitching/schemaRecreation.ts b/src/stitching/schemaRecreation.ts index 8ee083bcf7f..3ea08f8f665 100644 --- a/src/stitching/schemaRecreation.ts +++ b/src/stitching/schemaRecreation.ts @@ -33,7 +33,7 @@ import defaultMergedResolver from './defaultMergedResolver'; export function recreateType( type: GraphQLNamedType, resolveType: ResolveType, - keepResolvers: Boolean, + keepResolvers: boolean, ): GraphQLNamedType { if (type instanceof GraphQLObjectType) { const fields = type.getFields(); @@ -148,7 +148,7 @@ function parseLiteral(ast: ValueNode): any { export function fieldMapToFieldConfigMap( fields: GraphQLFieldMap, resolveType: ResolveType, - keepResolvers: Boolean, + keepResolvers: boolean, ): GraphQLFieldConfigMap { const result: GraphQLFieldConfigMap = {}; Object.keys(fields).forEach(name => { @@ -195,7 +195,7 @@ export function createResolveType( export function fieldToFieldConfig( field: GraphQLField, resolveType: ResolveType, - keepResolvers: Boolean, + keepResolvers: boolean, ): GraphQLFieldConfig { return { type: resolveType(field.type), diff --git a/src/transforms/FilterRootFields.ts b/src/transforms/FilterRootFields.ts index 47eebaf8636..bc7a2c1979b 100644 --- a/src/transforms/FilterRootFields.ts +++ b/src/transforms/FilterRootFields.ts @@ -6,7 +6,7 @@ export type RootFilter = ( operation: 'Query' | 'Mutation' | 'Subscription', fieldName: string, field: GraphQLField, -) => Boolean; +) => boolean; export default class FilterRootFields implements Transform { private transformer: TransformRootFields; diff --git a/src/transforms/FilterToSchema.ts b/src/transforms/FilterToSchema.ts index fd057a05e08..b78fd6ba1d4 100644 --- a/src/transforms/FilterToSchema.ts +++ b/src/transforms/FilterToSchema.ts @@ -65,8 +65,7 @@ function filterDocumentToSchema( const validFragments: Array = fragments.filter( (fragment: FragmentDefinitionNode) => { const typeName = fragment.typeCondition.name.value; - const type = targetSchema.getType(typeName); - return Boolean(type); + return Boolean(targetSchema.getType(typeName)); }, ); @@ -268,7 +267,7 @@ function resolveType(type: GraphQLType): GraphQLNamedType { } function union(...arrays: Array>): Array { - const cache: { [key: string]: Boolean } = {}; + const cache: { [key: string]: boolean } = {}; const result: Array = []; arrays.forEach(array => { array.forEach(item => { diff --git a/src/transforms/FilterTypes.ts b/src/transforms/FilterTypes.ts index ea8d24141a6..8d8196bd98d 100644 --- a/src/transforms/FilterTypes.ts +++ b/src/transforms/FilterTypes.ts @@ -5,9 +5,9 @@ import { Transform } from '../transforms/transforms'; import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; export default class FilterTypes implements Transform { - private filter: (type: GraphQLNamedType) => Boolean; + private filter: (type: GraphQLNamedType) => boolean; - constructor(filter: (type: GraphQLNamedType) => Boolean) { + constructor(filter: (type: GraphQLNamedType) => boolean) { this.filter = filter; } diff --git a/src/transforms/visitSchema.ts b/src/transforms/visitSchema.ts index 3f92aeabc4e..8d454fd25c2 100644 --- a/src/transforms/visitSchema.ts +++ b/src/transforms/visitSchema.ts @@ -38,7 +38,7 @@ export type TypeVisitor = ( export function visitSchema( schema: GraphQLSchema, visitor: SchemaVisitor, - stripResolvers?: Boolean, + stripResolvers?: boolean, ) { const types = {}; const resolveType = createResolveType(name => { From 9b834b4d36f27e186286a07d0968ccb50a0a3284 Mon Sep 17 00:00:00 2001 From: Mikhail Novikov Date: Mon, 23 Apr 2018 15:39:04 +0300 Subject: [PATCH 56/57] Remove qualified export, add wrapquery and extractfield --- docs/source/schema-stitching.md | 10 +- docs/source/schema-transforms.md | 31 +++ src/stitching/mergeSchemas.ts | 23 +-- src/test/testAlternateMergeSchemas.ts | 19 +- src/test/testTransforms.ts | 287 +++++++++++++++++++++++++- src/transforms/ExtractField.ts | 55 +++++ src/transforms/WrapQuery.ts | 75 +++++++ src/transforms/index.ts | 44 ++-- 8 files changed, 487 insertions(+), 57 deletions(-) create mode 100644 src/transforms/ExtractField.ts create mode 100644 src/transforms/WrapQuery.ts diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 4a1b7b8b98f..1d925b6a237 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -185,8 +185,10 @@ import { makeExecutableSchema, addMockFunctionsToSchema, mergeSchemas, - Transforms, transformSchema, + FilterRootFields, + RenameTypes, + RenameRootFields, } from 'graphql-tools'; // Mocked chirp schema; we don't want to worry about the schema @@ -212,11 +214,11 @@ addMockFunctionsToSchema({ schema: chirpSchema }); // create transform schema const transformedChirpSchema = transformSchema(chirpSchema, [ - new Transforms.FilterRootFields( + new FilterRootFields( (operation: string, rootField: string) => rootField !== 'chirpsByAuthorId' ), - new Transforms.RenameTypes((name: string) => `Chirp_${name}`), - new Transforms.RenameRootFields((name: string) => `Chirp_${name}`), + new RenameTypes((name: string) => `Chirp_${name}`), + new RenameRootFields((name: string) => `Chirp_${name}`), ]); ``` diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index 2cdd75f5f1e..399273a98e7 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -172,6 +172,37 @@ RenameRootFields( ### Other +* `ExractField({ from: Array, to: Array })` - move selection at `from` path to `to` path. + +* `WrapQuery( + path: Array, + wrapper: QueryWrapper, + extractor: (result: any) => any, + )` - wrap a selection at `path` using function `wrapper`. Apply `extractor` at the same path to get the result. This is used to get a result nested inside other result + +```js +transforms: [ + // Wrap document takes a subtree as an AST node + new WrapQuery( + // path at which to apply wrapping and extracting + ['userById'], + (subtree: SelectionSetNode) => ({ + // we create a wrapping AST Field + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + // that field is `address` + value: 'address', + }, + // Inside the field selection + selectionSet: subtree, + }), + // how to process the data result at path + result => result && result.address, + ), +], +``` + * `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, mapping: FieldToFragmentMapping)`: Replace the given fields with an inline fragment. Used by `mergeSchemas` to handle the `fragment` option. ```ts diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts index 380ec3412ef..c55cc8fb270 100644 --- a/src/stitching/mergeSchemas.ts +++ b/src/stitching/mergeSchemas.ts @@ -36,7 +36,11 @@ import { } from './schemaRecreation'; import delegateToSchema from './delegateToSchema'; import typeFromAST, { GetType } from './typeFromAST'; -import { Transform, Transforms } from '../transforms'; +import { + Transform, + ExpandAbstractTypes, + ReplaceFieldWithFragment, +} from '../transforms'; import mergeDeep from '../mergeDeep'; export type OnTypeConflict = ( @@ -317,14 +321,8 @@ function createMergeInfo( 'Use `mergeInfo.delegateToSchema and pass explicit schema instances.', ); const schema = guessSchemaByRootField(allSchemas, operation, fieldName); - const expandTransforms = new Transforms.ExpandAbstractTypes( - info.schema, - schema, - ); - const fragmentTransform = new Transforms.ReplaceFieldWithFragment( - schema, - fragments, - ); + const expandTransforms = new ExpandAbstractTypes(info.schema, schema); + const fragmentTransform = new ReplaceFieldWithFragment(schema, fragments); return delegateToSchema({ schema, operation, @@ -345,11 +343,8 @@ function createMergeInfo( ...options, transforms: [ ...(options.transforms || []), - new Transforms.ExpandAbstractTypes( - options.info.schema, - options.schema, - ), - new Transforms.ReplaceFieldWithFragment(options.schema, fragments), + new ExpandAbstractTypes(options.info.schema, options.schema), + new ReplaceFieldWithFragment(options.schema, fragments), ], }); }, diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index 5852fb9afbd..7e26181c656 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -3,7 +3,12 @@ import { expect } from 'chai'; import { graphql, GraphQLSchema } from 'graphql'; import mergeSchemas from '../stitching/mergeSchemas'; -import { Transforms, transformSchema } from '../transforms'; +import { + transformSchema, + FilterRootFields, + RenameTypes, + RenameRootFields, +} from '../transforms'; import { propertySchema, bookingSchema } from './testingSchemas'; let linkSchema = ` @@ -56,20 +61,20 @@ describe('merge schemas through transforms', () => { before(async () => { // namespace and strip schemas const transformedPropertySchema = transformSchema(propertySchema, [ - new Transforms.FilterRootFields( + new FilterRootFields( (operation: string, rootField: string) => 'Query.properties' === `${operation}.${rootField}`, ), - new Transforms.RenameTypes((name: string) => `Properties_${name}`), - new Transforms.RenameRootFields((name: string) => `Properties_${name}`), + new RenameTypes((name: string) => `Properties_${name}`), + new RenameRootFields((name: string) => `Properties_${name}`), ]); const transformedBookingSchema = transformSchema(bookingSchema, [ - new Transforms.FilterRootFields( + new FilterRootFields( (operation: string, rootField: string) => 'Query.bookings' === `${operation}.${rootField}`, ), - new Transforms.RenameTypes((name: string) => `Bookings_${name}`), - new Transforms.RenameRootFields( + new RenameTypes((name: string) => `Bookings_${name}`), + new RenameRootFields( (operation: string, name: string) => `Bookings_${name}`, ), ]); diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index d9fe280c070..dcd780e1e40 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -1,16 +1,30 @@ /* tslint:disable:no-unused-expression */ import { expect } from 'chai'; -import { GraphQLSchema, GraphQLNamedType, graphql } from 'graphql'; +import { + GraphQLSchema, + GraphQLNamedType, + graphql, + Kind, + SelectionSetNode, +} from 'graphql'; +import { makeExecutableSchema } from '../schemaGenerator'; import { propertySchema, bookingSchema } from './testingSchemas'; -import { Transforms, transformSchema } from '../transforms'; +import delegateToSchema from '../stitching/delegateToSchema'; +import { + transformSchema, + RenameTypes, + FilterTypes, + WrapQuery, + ExtractField, +} from '../transforms'; describe('transforms', () => { describe('rename type', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - new Transforms.RenameTypes( + new RenameTypes( (name: string) => ({ Property: 'House', @@ -72,7 +86,7 @@ describe('transforms', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - new Transforms.RenameTypes((name: string) => `Property_${name}`), + new RenameTypes((name: string) => `Property_${name}`), ]; schema = transformSchema(propertySchema, transforms); }); @@ -124,7 +138,7 @@ describe('transforms', () => { before(() => { const typeNames = ['ID', 'String', 'DateTime', 'Query', 'Booking']; const transforms = [ - new Transforms.FilterTypes( + new FilterTypes( (type: GraphQLNamedType) => typeNames.indexOf(type.name) >= 0, ), ]; @@ -191,4 +205,267 @@ describe('transforms', () => { }); }); }); + + describe('tree operations', () => { + let data: any; + let subSchema: GraphQLSchema; + let schema: GraphQLSchema; + before(() => { + data = { + u1: { + id: 'u1', + username: 'alice', + address: { + streetAddress: 'Windy Shore 21 A 7', + zip: '12345', + }, + }, + u2: { + id: 'u2', + username: 'bob', + address: { + streetAddress: 'Snowy Mountain 5 B 77', + zip: '54321', + }, + }, + }; + subSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String + address: Address + } + + type Address { + streetAddress: String + zip: String + } + + input UserInput { + id: ID! + username: String + } + + input AddressInput { + id: ID! + streetAddress: String + zip: String + } + + type Query { + userById(id: ID!): User + } + + type Mutation { + setUser(input: UserInput!): User + setAddress(input: AddressInput!): Address + } + `, + resolvers: { + Query: { + userById(parent, { id }) { + return data[id]; + }, + }, + Mutation: { + setUser(parent, { input }) { + if (data[input.id]) { + return { + ...data[input.id], + ...input, + }; + } + }, + setAddress(parent, { input }) { + if (data[input.id]) { + return { + ...data[input.id].address, + ...input, + }; + } + }, + }, + }, + }); + schema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String + address: Address + } + + type Address { + streetAddress: String + zip: String + } + + input UserInput { + id: ID! + username: String + streetAddress: String + zip: String + } + + type Query { + addressByUser(id: ID!): Address + } + + type Mutation { + setUserAndAddress(input: UserInput!): User + } + `, + resolvers: { + Query: { + addressByUser(parent, { id }, context, info) { + return delegateToSchema({ + schema: subSchema, + operation: 'query', + fieldName: 'userById', + args: { id }, + context, + info, + transforms: [ + // Wrap document takes a subtree as an AST node + new WrapQuery( + // path at which to apply wrapping and extracting + ['userById'], + (subtree: SelectionSetNode) => ({ + // we create a wrapping AST Field + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + // that field is `address` + value: 'address', + }, + // Inside the field selection + selectionSet: subtree, + }), + // how to process the data result at path + result => result && result.address, + ), + ], + }); + }, + }, + Mutation: { + async setUserAndAddress(parent, { input }, context, info) { + const addressResult = await delegateToSchema({ + schema: subSchema, + operation: 'mutation', + fieldName: 'setAddress', + args: { + input: { + id: input.id, + streetAddress: input.streetAddress, + zip: input.zip, + }, + }, + context, + info, + transforms: [ + // ExtractField takes a path from which to extract the query + // for delegation and path to which to move it + new ExtractField({ + from: ['setAddress', 'address'], + to: ['setAddress'], + }), + ], + }); + const userResult = await delegateToSchema({ + schema: subSchema, + operation: 'mutation', + fieldName: 'setUser', + args: { + input: { + id: input.id, + username: input.username, + }, + }, + context, + info, + }); + return { + ...userResult, + address: addressResult, + }; + }, + }, + }, + }); + }); + + it('wrapping delegation', async () => { + const result = await graphql( + schema, + ` + query { + addressByUser(id: "u1") { + streetAddress + zip + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + addressByUser: { + streetAddress: 'Windy Shore 21 A 7', + zip: '12345', + }, + }, + }); + }); + + it('extracting delegation', async () => { + const result = await graphql( + schema, + ` + mutation($input: UserInput!) { + setUserAndAddress(input: $input) { + username + address { + zip + streetAddress + } + } + } + + # fragment UserFragment on User { + # address { + # zip + # ...AddressFragment + # } + # } + # + # fragment AddressFragment on Address { + # streetAddress + # } + `, + {}, + {}, + { + input: { + id: 'u2', + username: 'new-username', + streetAddress: 'New Address 555', + zip: '22222', + }, + }, + ); + expect(result).to.deep.equal({ + data: { + setUserAndAddress: { + username: 'new-username', + address: { + streetAddress: 'New Address 555', + zip: '22222', + }, + }, + }, + }); + }); + }); }); diff --git a/src/transforms/ExtractField.ts b/src/transforms/ExtractField.ts new file mode 100644 index 00000000000..216d8f61e7c --- /dev/null +++ b/src/transforms/ExtractField.ts @@ -0,0 +1,55 @@ +import { visit, Kind, SelectionSetNode, BREAK, FieldNode } from 'graphql'; +import { Transform, Request } from '../Interfaces'; + +export default class ExtractField implements Transform { + private from: Array; + private to: Array; + + constructor({ from, to }: { from: Array; to: Array }) { + this.from = from; + this.to = to; + } + + public transformRequest(originalRequest: Request): Request { + let fromSelection: SelectionSetNode; + const ourPathFrom = JSON.stringify(this.from); + const ourPathTo = JSON.stringify(this.to); + let fieldPath: Array = []; + visit(originalRequest.document, { + [Kind.FIELD]: { + enter: (node: FieldNode) => { + fieldPath.push(node.name.value); + if (ourPathFrom === JSON.stringify(fieldPath)) { + fromSelection = node.selectionSet; + return BREAK; + } + }, + leave: (node: FieldNode) => { + fieldPath.pop(); + }, + }, + }); + + fieldPath = []; + const newDocument = visit(originalRequest.document, { + [Kind.FIELD]: { + enter: (node: FieldNode) => { + fieldPath.push(node.name.value); + if (ourPathTo === JSON.stringify(fieldPath) && fromSelection) { + return { + ...node, + selectionSet: fromSelection, + }; + } + }, + leave: (node: FieldNode) => { + fieldPath.pop(); + }, + }, + }); + return { + ...originalRequest, + document: newDocument, + }; + } +} diff --git a/src/transforms/WrapQuery.ts b/src/transforms/WrapQuery.ts new file mode 100644 index 00000000000..2c640b13307 --- /dev/null +++ b/src/transforms/WrapQuery.ts @@ -0,0 +1,75 @@ +import { + FieldNode, + visit, + Kind, + SelectionNode, + SelectionSetNode, +} from 'graphql'; +import { Transform, Request, Result } from '../Interfaces'; + +export type QueryWrapper = (subtree: SelectionSetNode) => SelectionNode; + +export default class WrapQuery implements Transform { + private wrapper: QueryWrapper; + private extractor: (result: any) => any; + private path: Array; + + constructor( + path: Array, + wrapper: QueryWrapper, + extractor: (result: any) => any, + ) { + this.path = path; + this.wrapper = wrapper; + this.extractor = extractor; + } + + public transformRequest(originalRequest: Request): Request { + const document = originalRequest.document; + const fieldPath: Array = []; + const ourPath = JSON.stringify(this.path); + const newDocument = visit(document, { + [Kind.FIELD]: { + enter: (node: FieldNode) => { + fieldPath.push(node.name.value); + if (ourPath === JSON.stringify(fieldPath)) { + const selection = this.wrapper(node.selectionSet); + return { + ...node, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [selection], + }, + }; + } + }, + leave: (node: FieldNode) => { + fieldPath.pop(); + }, + }, + }); + return { + ...originalRequest, + document: newDocument, + }; + } + + public transformResult(originalResult: Result): Result { + let data = originalResult.data; + if (data) { + const path = [...this.path]; + while (path.length > 1) { + const next = path.unshift(); + if (data[next]) { + data = data[next]; + } + } + data[path[0]] = this.extractor(data[path[0]]); + } + + return { + data, + errors: originalResult.errors, + }; + } +} diff --git a/src/transforms/index.ts b/src/transforms/index.ts index 1af4f76bd2c..db76efbc221 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -3,30 +3,20 @@ export { Transform }; export { default as transformSchema } from './transformSchema'; -import AddArgumentsAsVariables from './AddArgumentsAsVariables'; -import CheckResultAndHandleErrors from './CheckResultAndHandleErrors'; -import ReplaceFieldWithFragment from './ReplaceFieldWithFragment'; -import AddTypenameToAbstract from './AddTypenameToAbstract'; -import FilterToSchema from './FilterToSchema'; -import RenameTypes from './RenameTypes'; -import FilterTypes from './FilterTypes'; -import TransformRootFields from './TransformRootFields'; -import RenameRootFields from './RenameRootFields'; -import FilterRootFields from './FilterRootFields'; -import ExpandAbstractTypes from './ExpandAbstractTypes'; - -export const Transforms: { - [name: string]: { new (...args: Array): Transform }; -} = { - AddArgumentsAsVariables, - CheckResultAndHandleErrors, - ReplaceFieldWithFragment, - AddTypenameToAbstract, - FilterToSchema, - RenameTypes, - FilterTypes, - TransformRootFields, - RenameRootFields, - FilterRootFields, - ExpandAbstractTypes, -}; +export { default as AddArgumentsAsVariables } from './AddArgumentsAsVariables'; +export { + default as CheckResultAndHandleErrors, +} from './CheckResultAndHandleErrors'; +export { + default as ReplaceFieldWithFragment, +} from './ReplaceFieldWithFragment'; +export { default as AddTypenameToAbstract } from './AddTypenameToAbstract'; +export { default as FilterToSchema } from './FilterToSchema'; +export { default as RenameTypes } from './RenameTypes'; +export { default as FilterTypes } from './FilterTypes'; +export { default as TransformRootFields } from './TransformRootFields'; +export { default as RenameRootFields } from './RenameRootFields'; +export { default as FilterRootFields } from './FilterRootFields'; +export { default as ExpandAbstractTypes } from './ExpandAbstractTypes'; +export { default as ExtractField } from './ExtractField'; +export { default as WrapQuery } from './WrapQuery'; From adb1a7f24dd028b7e3a4536031c1bfc591caae4a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 23 Apr 2018 17:14:51 -0400 Subject: [PATCH 57/57] Code coverage fixes. Since we're planning to bump the graphql-tools major version to 3.0.0, I think it's fine to hard-deprecate passing positional arguments to delegateToSchema, by throwing an exception rather than warning/continuing. There's no reason to keep using the old style, especially now that the fragmentReplacements argument has been replaced by transforms. --- src/stitching/delegateToSchema.ts | 54 ++++++------------------------- src/test/testMergeSchemas.ts | 44 +++++++++++++++++++++---- 2 files changed, 47 insertions(+), 51 deletions(-) diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts index 2ac90c340d1..e5f95c903ba 100644 --- a/src/stitching/delegateToSchema.ts +++ b/src/stitching/delegateToSchema.ts @@ -11,72 +11,36 @@ import { execute, validate, VariableDefinitionNode, - print, - GraphQLResolveInfo, - InlineFragmentNode, GraphQLSchema, } from 'graphql'; + import { Operation, Request, IDelegateToSchemaOptions, - Transform, } from '../Interfaces'; + import { applyRequestTransforms, applyResultTransforms, } from '../transforms/transforms'; + import AddArgumentsAsVariables from '../transforms/AddArgumentsAsVariables'; import FilterToSchema from '../transforms/FilterToSchema'; import AddTypenameToAbstract from '../transforms/AddTypenameToAbstract'; import CheckResultAndHandleErrors from '../transforms/CheckResultAndHandleErrors'; -import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; -export default async function delegateToSchema( +export default function delegateToSchema( options: IDelegateToSchemaOptions | GraphQLSchema, - ...args: Array -): Promise; -export default async function delegateToSchema( - options: IDelegateToSchemaOptions | GraphQLSchema, - fragmentReplacements: { - [typeName: string]: { [fieldName: string]: InlineFragmentNode }; - }, - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, - transforms?: Array, + ...args: any[], ): Promise { if (options instanceof GraphQLSchema) { - const schema = options; - console.warn( - 'Argument list is a deprecated. Pass object of parameters ' + - 'to delegate to schema', + throw new Error( + 'Passing positional arguments to delegateToSchema is a deprecated. ' + + 'Please pass named parameters instead.' ); - const fragments: Array<{ field: string; fragment: string }> = []; - Object.keys(fragmentReplacements).forEach(typeName => { - const typeFragments = fragmentReplacements[typeName]; - Object.keys(typeFragments).forEach(field => { - fragments.push({ field, fragment: print(typeFragments[field]) }); - }); - }); - const newOptions: IDelegateToSchemaOptions = { - schema, - operation, - fieldName, - args, - context, - info, - transforms: [ - new ReplaceFieldWithFragment(schema, fragments), - ...(transforms || []), - ], - }; - return delegateToSchemaImplementation(newOptions); - } else { - return delegateToSchemaImplementation(options); } + return delegateToSchemaImplementation(options); } async function delegateToSchemaImplementation( diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index b4aaf6ce43e..4ae2d4171d2 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -306,6 +306,36 @@ if (process.env.GRAPHQL_VERSION === '^0.11') { `; } +// Miscellaneous typeDefs that exercise uncommon branches for the sake of +// code coverage. +const codeCoverageTypeDefs = ` + interface SyntaxNode { + type: String + } + + type Statement implements SyntaxNode { + type: String + } + + type Expression implements SyntaxNode { + type: String + } + + union ASTNode = Statement | Expression + + enum Direction { + NORTH + SOUTH + EAST + WEST + } + + input WalkingPlan { + steps: Int + direction: Direction + } +`; + testCombinations.forEach(async combination => { describe('merging ' + combination.name, () => { let mergedSchema: GraphQLSchema, @@ -329,23 +359,25 @@ testCombinations.forEach(async combination => { linkSchema, loneExtend, localSubscriptionSchema, + codeCoverageTypeDefs, ], resolvers: { Property: { bookings: { fragment: '... on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ - schema: bookingSchema, - operation: 'query', - fieldName: 'bookingsByPropertyId', - args: { + // Use the old mergeInfo.delegate API just this once, to make + // sure it continues to work. + return info.mergeInfo.delegate( + 'query', + 'bookingsByPropertyId', + { propertyId: parent.id, limit: args.limit ? args.limit : null, }, context, info, - }); + ); }, }, },