diff --git a/packages/apollo-gateway/src/__tests__/CucumberREADME.md b/packages/apollo-gateway/src/__tests__/CucumberREADME.md index 25d4dc90438..b7a26146ad5 100644 --- a/packages/apollo-gateway/src/__tests__/CucumberREADME.md +++ b/packages/apollo-gateway/src/__tests__/CucumberREADME.md @@ -2,9 +2,9 @@ ## Introduction -There are two files used to test the query plan builder: +There two kinds of files used to test the query plan builder: -1. [build-query-plan.feature](./build-query-plan.feature): Programming-language agnostic files written in a format called [Gherkin](https://cucumber.io/docs/gherkin/reference/) for [Cucumber](https://cucumber.io/). +1. `.feature` files written in a programming-language agnostic format called [Gherkin](https://cucumber.io/docs/gherkin/reference/) for [Cucumber](https://cucumber.io/). 2. [queryPlanCucumber.test.ts](./queryPlanCucumber.test.ts): The implementation which provides coverage for the Gherkin-specified behavior. > If you're not familiar with Cucumber or BDD, check out [this video](https://youtu.be/lC0jzd8sGIA) for a great introduction to the concepts involved. Cucumber has test runners in multiple languages, allowing a test spec to be written in plain English and then individual implementations of the test suite can describe how they would like tests to be run for their specific implementation. For Java, Kotlin, Ruby, and JavaScript, Cucumber even has a [10-minute tutorial](https://cucumber.io/docs/guides/10-minute-tutorial/) to help get started. @@ -52,6 +52,9 @@ Scenario: should not confuse union types with overlapping field names """ ``` +Currently, this is the format of all of the tests contained in this test suite, +but future tests will add more complicated behavior. + There can be multiple of any kind of step using the `And` keyword. In the following example, there are 2 `Given` steps. One represented by the `Given` keyword itself, and another represented with the `And` keyword. ``` diff --git a/packages/apollo-gateway/src/__tests__/integration/abstract-types.feature b/packages/apollo-gateway/src/__tests__/integration/abstract-types.feature new file mode 100644 index 00000000000..45715d01873 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/abstract-types.feature @@ -0,0 +1,648 @@ +Feature: Query Planner > Abstract Types + +Scenario: handles an abstract type from the base service + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + upc + name + price + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{upc __typename isbn price}...on Furniture{upc name price}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + """ + +Scenario: can request fields on extended interfaces + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + inStock + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename sku}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "inventory", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "sku" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{inStock}...on Furniture{inStock}}}" + } + } + ] + } + } + """ + +Scenario: can request fields on extended types that implement an interface + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + inStock + ... on Furniture { + isHeavy + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename sku}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "inventory", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "sku" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{inStock}...on Furniture{inStock isHeavy}}}" + } + } + ] + } + } + """ + +Scenario: prunes unfilled type conditions + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + inStock + ... on Furniture { + isHeavy + } + ... on Book { + isCheckedOut + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename sku}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "inventory", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "sku" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{inStock isCheckedOut}...on Furniture{inStock isHeavy}}}" + } + } + ] + } + } + """ + +Scenario: fetches interfaces returned from other services + Given query + """ + query GetUserAndProducts { + me { + reviews { + product { + price + ... on Book { + title + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{price}...on Furniture{price}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{title}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: fetches composite fields from a foreign type casted to an interface [@provides field + Given query + """ + query GetUserAndProducts { + me { + reviews { + product { + price + ... on Book { + name + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{price}...on Furniture{price}}}" + } + }, + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + ] + } + ] + } + } + """ + +Scenario: allows for extending an interface from another service with fields + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + reviews { + body + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{body}}...on Furniture{reviews{body}}}}" + } + } + ] + } + } + """ + +Scenario: handles unions from the same service + Given query + """ + query GetUserAndProducts { + me { + reviews { + product { + price + ... on Furniture { + brand { + ... on Ikea { + asile + } + ... on Amazon { + referrer + } + } + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{price}...on Furniture{price brand{__typename ...on Ikea{asile}...on Amazon{referrer}}}}}" + } + } + ] + } + } + """ + +# can't test this yet -- original test overwrites schema def, which we don't support yet +# Scenario: doesn't expand interfaces with inline type conditions if all possibilities are fufilled by one service +# Given query +# """ +# query GetProducts { +# topProducts { +# name +# } +# } +# """ +# Then query plan +# """ +# {} +# """ diff --git a/packages/apollo-gateway/src/__tests__/integration/aliases.feature b/packages/apollo-gateway/src/__tests__/integration/aliases.feature new file mode 100644 index 00000000000..be2ed66a66e --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/aliases.feature @@ -0,0 +1,304 @@ +Feature: Query Planning > Aliases + + +Scenario: supports simple aliases + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + title: name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name title:name}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name title:name}}}" + } + } + ] + } + } + """ + +Scenario: supports aliases of root fields on subservices + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + title: name + reviews { + body + } + productReviews: reviews { + body + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name title:name __typename upc}}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name title:name}}}" + } + } + ] + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{body}productReviews:reviews{body}}...on Furniture{reviews{body}productReviews:reviews{body}}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: supports aliases of nested fields on subservices + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + title: name + reviews { + content: body + body + } + productReviews: reviews { + body + reviewer: author { + name: username + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name title:name __typename upc}}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name title:name}}}" + } + } + ] + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{body content:body}productReviews:reviews{body reviewer:author{name:username}}}...on Furniture{reviews{body content:body}productReviews:reviews{body reviewer:author{name:username}}}}}" + } + } + ] + } + ] + } + } + """ diff --git a/packages/apollo-gateway/src/__tests__/integration/boolean.feature b/packages/apollo-gateway/src/__tests__/integration/boolean.feature new file mode 100644 index 00000000000..b008863eee1 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/boolean.feature @@ -0,0 +1,328 @@ +Feature: Query Planning > Boolean + +Scenario: supports @skip when a boolean condition is met + Given query + """ + query GetReviewers { + topReviews { + body + author @skip(if: true) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@skip(if:true){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: supports @skip when a boolean condition is met (variable driven) + Given query + """ + query GetReviewers($skip: Boolean! = true) { + topReviews { + body + author @skip(if: $skip) { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["skip"], + "operation": "query($skip:Boolean!=true){topReviews{body author@skip(if:$skip){username}}}" + } + } + """ + +Scenario: supports @skip when a boolean condition is not met + Given query + """ + query GetReviewers { + topReviews { + body + author @skip(if: false) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@skip(if:false){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: supports @skip when a boolean condition is not met (variable driven) + Given query + """ + query GetReviewers($skip: Boolean!) { + topReviews { + body + author @skip(if: $skip) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["skip"], + "operation": "query($skip:Boolean!){topReviews{body author@skip(if:$skip){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: supports @include when a boolean condition is not met + Given query + """ + query GetReviewers { + topReviews { + body + author @include(if: false) { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@include(if:false){username}}}" + } + } + """ + +Scenario: supports @include when a boolean condition is not met (variable driven) + Given query + """ + query GetReviewers($include: Boolean! = false) { + topReviews { + body + author @include(if: $include) { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["include"], + "operation": "query($include:Boolean!=false){topReviews{body author@include(if:$include){username}}}" + } + } + """ + +Scenario: supports @include when a boolean condition is met + Given query + """ + query GetReviewers { + topReviews { + body + author @include(if: true) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@include(if:true){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + + + +Scenario: supports @include when a boolean condition is met (variable driven) + Given query + """ + query GetReviewers($include: Boolean!) { + topReviews { + body + author @include(if: $include) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["include"], + "operation": "query($include:Boolean!){topReviews{body author@include(if:$include){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ diff --git a/packages/apollo-gateway/src/__tests__/integration/custom-directives.feature b/packages/apollo-gateway/src/__tests__/integration/custom-directives.feature new file mode 100644 index 00000000000..864ca4b4f53 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/custom-directives.feature @@ -0,0 +1,73 @@ +Feature: Query Planning > Custom Directives + +Scenario: successfully passes directives along in requests to an underlying service + Given query + """ + query GetReviewers { + topReviews { + body @stream + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body@stream}}" + } + } + """ + +Scenario: successfully passes directives and their variables along in requests to underlying services + Given query + """ + query GetReviewers { + topReviews { + body @stream + author @transform(from: "JSON") { + name @stream + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body@stream author@transform(from:\"JSON\"){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name@stream}}}" + } + } + ] + } + } + """ diff --git a/packages/apollo-gateway/src/__tests__/integration/execution-style.feature b/packages/apollo-gateway/src/__tests__/integration/execution-style.feature new file mode 100644 index 00000000000..87f86deb2b8 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/execution-style.feature @@ -0,0 +1,37 @@ +Feature: Query Planning > Execution Style + +Scenario: supports parallel root fields + Given query + """ + query GetUserAndReviews { + me { + username + } + topReviews { + body + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Parallel", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username}}" + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body}}" + } + ] + } + } + """ diff --git a/packages/apollo-gateway/src/__tests__/integration/fragments.feature b/packages/apollo-gateway/src/__tests__/integration/fragments.feature new file mode 100644 index 00000000000..72de4a64fc0 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/fragments.feature @@ -0,0 +1,321 @@ +Feature: Query Planning > Fragments + +# important thing here: fetches to accounts service +Scenario: supports inline fragments (one level) + Given query + """ + query GetUser { + me { + ... on User { + username + } + } + } + """ + Then query plan + """ + {"kind":"QueryPlan","node":{"kind":"Fetch","serviceName":"accounts","variableUsages":[],"operation":"{me{username}}"}} + """ + +# important things: calls [accounts, reviews, products, books] +Scenario: supports inline fragments (multi level) + Given query + """ + query GetUser { + me { + ... on User { + username + reviews { + ... on Review { + body + product { + ... on Product { + ... on Book { + title + } + ... on Furniture { + name + } + } + } + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username __typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{body product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{title}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: supports named fragments (one level) + Given query + """ + query GetUser { + me { + ...userDetails + } + } + + fragment userDetails on User { + username + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username}}" + } + } + """ + +# important: calls accounts service +Scenario: supports multiple named fragments (one level, mixed ordering) + Given query + """ + fragment userInfo on User { + name + } + query GetUser { + me { + ...userDetails + ...userInfo + } + } + + fragment userDetails on User { + username + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username name}}" + } + } + """ + +Scenario: supports multiple named fragments (multi level, mixed ordering) + Given query + """ + fragment reviewDetails on Review { + body + } + query GetUser { + me { + ...userDetails + } + } + + fragment userDetails on User { + username + reviews { + ...reviewDetails + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username __typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{body}}}}" + } + } + ] + } + } + """ + +# important: calls accounts & reviews, uses `variableUsages` +Scenario: supports variables within fragments + Given query + """ + query GetUser($format: Boolean) { + me { + ...userDetails + } + } + + fragment userDetails on User { + username + reviews { + body(format: $format) + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username __typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": ["format"], + "operation": "query($representations:[_Any!]!$format:Boolean){_entities(representations:$representations){...on User{reviews{body(format:$format)}}}}" + } + } + ] + } + } + """ + +Scenario: supports root fragments + Given query + """ + query GetUser { + ... on Query { + me { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username}}" + } + } + """ diff --git a/packages/apollo-gateway/src/__tests__/integration/mutations.feature b/packages/apollo-gateway/src/__tests__/integration/mutations.feature new file mode 100644 index 00000000000..f6eaf15b55d --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/mutations.feature @@ -0,0 +1,331 @@ +Feature: Query Planning > Mutations + +Scenario: supports mutations + Given query + """ + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": ["username", "password"], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": ["login"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["login", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + } + ] + } + } + """ + +Scenario: mutations across service boundaries + Given query + """ + mutation Review($upc: String!, $body: String!) { + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["upc", "body"], + "operation": "mutation($upc:String!$body:String!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": ["reviewProduct"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + } + + """ + +Scenario: multiple root mutations + Given query + """ + mutation LoginAndReview( + $username: String! + $password: String! + $upc: String! + $body: String! + ) { + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": ["username", "password"], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": ["login"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["login", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["upc", "body"], + "operation": "mutation($upc:String!$body:String!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": ["reviewProduct"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + } + """ + +# important: order: Review > Update > Login > Delete +Scenario: multiple root mutations with correct service order + Given query + """ + mutation LoginAndReview( + $upc: String! + $body: String! + $updatedReview: UpdateReviewInput! + $username: String! + $password: String! + $reviewId: ID! + ) { + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + upc + } + } + updateReview(review: $updatedReview) { + id + body + } + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + deleteReview(id: $reviewId) + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["upc", "body", "updatedReview"], + "operation": "mutation($upc:String!$body:String!$updatedReview:UpdateReviewInput!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{upc}}updateReview(review:$updatedReview){id body}}" + }, + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": ["username", "password"], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": ["login"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["login", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["reviewId"], + "operation": "mutation($reviewId:ID!){deleteReview(id:$reviewId)}" + } + ] + } + } + """ + diff --git a/packages/apollo-gateway/src/__tests__/integration/provides.feature b/packages/apollo-gateway/src/__tests__/integration/provides.feature new file mode 100644 index 00000000000..f6d4fb54ca5 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/provides.feature @@ -0,0 +1,76 @@ +Feature: Query Planner > Provides + +Scenario: does not have to go to another service when field is given + Given query + """ + query GetReviewers { + topReviews { + author { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{author{username}}}" + } + } + """ + +# make sure the accounts service doesn't have User.username in its query +Scenario: does not load fields provided even when going to other service + Given query + """ + query GetReviewers { + topReviews { + author { + username + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{author{username __typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ diff --git a/packages/apollo-gateway/src/__tests__/integration/requires.feature b/packages/apollo-gateway/src/__tests__/integration/requires.feature new file mode 100644 index 00000000000..823746481d2 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/requires.feature @@ -0,0 +1,124 @@ +Feature: Query Planning > requires + +# requires { isbn, title, year } from books service +Scenario: supports passing additional fields defined by a requires + Given query + """ + query GetReviwedBookNames { + me { + reviews { + product { + ... on Book { + name + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + + """ + +# todo: can we do this without redefining schemas? +# Scenario: collapses nested requires +# Given query +# """ +# query UserFavorites { +# user { +# favoriteColor +# favoriteAnimal +# } +# } +# """ +# Then query plan +# """ +# {} +# """ + +# Scenario: collapses nested requires with user-defined fragments +# Given query +# """ + +# """ +# Then query plan +# """ + # {} +# """ diff --git a/packages/apollo-gateway/src/__tests__/integration/single-service.feature b/packages/apollo-gateway/src/__tests__/integration/single-service.feature new file mode 100644 index 00000000000..f8d7df43a80 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/single-service.feature @@ -0,0 +1,65 @@ +Feature: Query Planning > Single Service + +# I don't think we need to move this test -- looks way too simple, maybe an +# early-written test? +# Scenario: executes a query plan over concrete types + +# this test looks a bit deceiving -- this is the correct query plan, but when +# executed, __typename should be returned +Scenario: does not remove __typename on root types + Given query + """ + query GetUser { + __typename + } + """ + Then query plan + """ + {"kind":"QueryPlan"} + """ + +Scenario: does not remove __typename if that is all that is requested on an entity + Given query + """ + query GetUser { + me { + __typename + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename}}" + } + } + """ + +Scenario: does not remove __typename if that is all that is requested on a value type + Given query + """ + query GetUser { + me { + account { + __typename + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{account{__typename}}}" + } + } + """ diff --git a/packages/apollo-gateway/src/__tests__/integration/value-types.feature b/packages/apollo-gateway/src/__tests__/integration/value-types.feature new file mode 100644 index 00000000000..f90a6f795a6 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/value-types.feature @@ -0,0 +1,118 @@ +Feature: Query Planner > Value Types + +Scenario: resolves value types within their respective services + Given query + """ + fragment Metadata on MetadataOrError { + ... on KeyValue { + key + value + } + ... on Error { + code + message + } + } + + query ProducsWithMetadata { + topProducts(first: 10) { + upc + ... on Book { + metadata { + ...Metadata + } + } + ... on Furniture { + metadata { + ...Metadata + } + } + reviews { + metadata { + ...Metadata + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{topProducts(first:10){__typename ...on Book{upc __typename isbn}...on Furniture{upc metadata{__typename ...on KeyValue{key value}...on Error{code message}}__typename}}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{metadata{__typename ...on KeyValue{key value}...on Error{code message}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{metadata{__typename ...on KeyValue{key value}...on Error{code message}}}}...on Furniture{reviews{metadata{__typename ...on KeyValue{key value}...on Error{code message}}}}}}" + } + } + ] + } + ] + } + } + """ + +# can't test as-is without modifying resolvers +# Scenario: resolves @provides fields on value types correctly via contrived example +# Given query +# """ + +# """ +# Then query plan +# """ +# {} +# """ diff --git a/packages/apollo-gateway/src/__tests__/integration/variables.feature b/packages/apollo-gateway/src/__tests__/integration/variables.feature new file mode 100644 index 00000000000..2e5b649a660 --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/integration/variables.feature @@ -0,0 +1,245 @@ +Feature: Query Planning > Variables + +# calls product with variable +Scenario: passes variables to root fields + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + """ + +# calls product with default variable +Scenario: supports default variables in a variable definition + Given query + """ + query GetProduct($upc: String = "1") { + product(upc: $upc) { + name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String=\"1\"){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + """ + +# calls reviews service with variable; calls accounts +Scenario: passes variables to nested services + Given query + """ + query GetProductsForUser($format: Boolean) { + me { + reviews { + body(format: $format) + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": ["format"], + "operation": "query($representations:[_Any!]!$format:Boolean){_entities(representations:$representations){...on User{reviews{body(format:$format)}}}}" + } + } + ] + } + } + """ + +# XXX I think this test relies on execution to use the default variable, not the query plan +# Scenario: works with default variables in the schema +# Given query +# """ +# query LibraryUser($libraryId: ID!, $userId: ID) { +# library(id: $libraryId) { +# userAccount(id: $userId) { +# id +# name +# } +# } +# } +# """ +# Then query plan +# """ +# { +# "kind": "QueryPlan", +# "node": { +# "kind": "Sequence", +# "nodes": [ +# { +# "kind": "Fetch", +# "serviceName": "books", +# "variableUsages": ["libraryId"], +# "operation": "query($libraryId:ID!){library(id:$libraryId){__typename id name}}" +# }, +# { +# "kind": "Flatten", +# "path": ["library"], +# "node": { +# "kind": "Fetch", +# "serviceName": "accounts", +# "requires": [ +# { +# "kind": "InlineFragment", +# "typeCondition": "Library", +# "selections": [ +# { "kind": "Field", "name": "__typename" }, +# { "kind": "Field", "name": "id" }, +# { "kind": "Field", "name": "name" } +# ] +# } +# ], +# "variableUsages": ["userId"], +# "operation": "query($representations:[_Any!]!$userId:ID){_entities(representations:$representations){...on Library{userAccount(id:$userId){id name}}}}" +# } +# } +# ] +# } +# } +# """ + + diff --git a/packages/apollo-gateway/src/__tests__/queryPlanCucumber.test.ts b/packages/apollo-gateway/src/__tests__/queryPlanCucumber.test.ts index 120e3a68179..aba258a91b4 100644 --- a/packages/apollo-gateway/src/__tests__/queryPlanCucumber.test.ts +++ b/packages/apollo-gateway/src/__tests__/queryPlanCucumber.test.ts @@ -1,20 +1,29 @@ import gql from 'graphql-tag'; import { GraphQLSchemaValidationError } from 'apollo-graphql'; import { defineFeature, loadFeature } from 'jest-cucumber'; -import { DocumentNode, GraphQLSchema, GraphQLError, Kind } from 'graphql'; +import { DocumentNode, Kind } from 'graphql'; import { QueryPlan } from '../..'; import { buildQueryPlan, buildOperationContext, BuildQueryPlanOptions } from '../buildQueryPlan'; import { getFederatedTestingSchema } from './execution-utils'; -const buildQueryPlanFeature = loadFeature( - './packages/apollo-gateway/src/__tests__/build-query-plan.feature' -); - +const testDir = './packages/apollo-gateway/src/__tests__/'; const features = [ - buildQueryPlanFeature -]; + testDir + 'build-query-plan.feature', + testDir + 'integration/fragments.feature', + testDir + 'integration/requires.feature', + testDir + 'integration/variables.feature', + testDir + 'integration/mutations.feature', + testDir + 'integration/boolean.feature', + testDir + 'integration/provides.feature', + testDir + 'integration/value-types.feature', + testDir + 'integration/abstract-types.feature', + testDir + 'integration/aliases.feature', + testDir + 'integration/custom-directives.feature', + testDir + 'integration/execution-style.feature', + testDir + 'integration/single-service.feature', +].map(path => loadFeature(path)); features.forEach((feature) => { defineFeature(feature, (test) => { diff --git a/packages/apollo-gateway/src/buildQueryPlan.ts b/packages/apollo-gateway/src/buildQueryPlan.ts index ac528f5a340..50103bf0bba 100644 --- a/packages/apollo-gateway/src/buildQueryPlan.ts +++ b/packages/apollo-gateway/src/buildQueryPlan.ts @@ -70,6 +70,7 @@ export function buildQueryPlan( operationContext: OperationContext, options: BuildQueryPlanOptions = { autoFragmentization: false }, ): QueryPlan { + // this gets us the schema, operation, all fragment defs, etc const context = buildQueryPlanningContext(operationContext, options); if (context.operation.operation === 'subscription') { @@ -83,14 +84,18 @@ export function buildQueryPlan( const isMutation = context.operation.operation === 'mutation'; + // root field nodes const fields = collectFields( context, context.newScope(rootType), context.operation.selectionSet, ); + // Mutations are a bit more specific in how FetchGroups can be built, as some // calls to the same service may need to be executed serially. + // `groups` here is a list of fetch groups, split up by service, either + // serially or in parallel const groups = isMutation ? splitRootFieldsSerially(context, fields) : splitRootFields(context, fields); @@ -514,8 +519,17 @@ function splitFields( context: QueryPlanningContext, path: ResponsePath, fields: FieldSet, + // this function is for finding a FetchGroup to add a field to. The reason we have + // to use an outside function for this is that this lookup process happens differently + // for queries and mutations. In queries, we can just batch all fields from the same + // service togethere, whereas in mutations, we have to look at the last group + // that was constructed. If it was of the same service as the field in question, + // we can use that group but otherwise we need to create a new group. groupForField: (field: Field) => FetchGroup, ) { + // group by the name the client will see -- alias or name, whichever is there + // Each iteration of the loop contains a set of fields with the same response name + // [FieldDef, FieldDef] for (const fieldsForResponseName of groupByResponseName(fields).values()) { for (const [parentType, fieldsForParentType] of groupByParentType(fieldsForResponseName)) { // Field nodes that share the same response name and parent type are guaranteed @@ -641,6 +655,9 @@ function splitFields( } } +/** + * returns the complete field info which includes scope, def, and ast node for a field + */ function completeField( context: QueryPlanningContext, scope: Scope, @@ -651,6 +668,7 @@ function completeField( const { fieldNode, fieldDef } = fields[0]; const returnType = getNamedType(fieldDef.type); + // if scalar return type for field, just return the field if (!isCompositeType(returnType)) { // FIXME: We should look at all field nodes to make sure we take directives // into account (or remove directives for the time being). @@ -658,11 +676,14 @@ function completeField( } else { // For composite types, we need to recurse. + // if the fieldtype is a listtype, it also appends @ const fieldPath = addPath(path, getResponseName(fieldNode), fieldDef.type); const subGroup = new FetchGroup(parentGroup.serviceName); subGroup.mergeAt = fieldPath; + // figure out what fields can be provided by the service responsible for the parent group + // and add them to the subGroup subGroup.providedFields = context.getProvidedFields( fieldDef, parentGroup.serviceName, @@ -759,10 +780,15 @@ function getInternalFragment( return context.internalFragments.get(key)!; } +/** + * Accepts a selection set of AST nodes, Gathers field definitions from the + * schema, and pushes those definitions back to `fields` + */ function collectFields( context: QueryPlanningContext, scope: Scope, selectionSet: SelectionSetNode, + // this is mutated and appended to fields: FieldSet = [], visitedFragmentNames: { [fragmentName: string]: boolean } = Object.create( null, @@ -792,16 +818,20 @@ function collectFields( case Kind.FRAGMENT_SPREAD: const fragmentName = selection.name.value; + // check to make sure the spread fragments exists in the parsed operation const fragment = context.fragments[fragmentName]; if (!fragment) { continue; } + // new scope based on the parent type of the fragment + //(like `...BookFields` where `fragment BookFields on Book` would be `Book`) const newScope = context.newScope(getFragmentCondition(fragment), scope); if (newScope.possibleTypes.length === 0) { continue; } + // this makes sure we don't collect the fragment twice in a single selection if (visitedFragmentNames[fragmentName]) { continue; } @@ -1009,6 +1039,10 @@ export class QueryPlanningContext { return fieldDef; } + /** + * possible types includes all types in unions or implementing types + * of interfaces + */ getPossibleTypes( type: GraphQLAbstractType | GraphQLObjectType, ): ReadonlyArray {