Skip to content

Commit 2894a1e

Browse files
Sylvain LebresneSylvain Lebresne
Sylvain Lebresne
authored and
Sylvain Lebresne
committed
Fix potential bug when an @interfaceObject has a @requires (#2524)
When an `@interfaceObject` type has a field with a `@requires` and the query requests that field only for some specific implementations of the corresponding interface, then the generated query plan was sometimes invalid and could result in an invalid query to a subgraph (against a subgraph that rely on `@apollo/subgraph`, this lead the subgraph to produce an error message looking like `"The _entities resolver tried to load an entity for type X, but no object or interface type of that name was found in the schema"`). The underlying reason is that the plan was mistakenly requesting the required fields for any object of the interface (corresponding to the `@interfaceObject` in question), instead of only requesting them only for only the implementation types it needed to. Not only is that inefficient in principle, but this lead to invalid fetches because the "rewrite" logic used to fixup the `__typename` for `@interfaceObject` under the hood was only rewriting the types of the implementation types in question (only expecting those to need rewrite, which technically was correct) but was mistakenly getting values for other implementation types.
1 parent 11f2d7c commit 2894a1e

File tree

4 files changed

+300
-49
lines changed

4 files changed

+300
-49
lines changed

.changeset/spotty-monkeys-clean.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@apollo/query-planner": patch
3+
"@apollo/federation-internals": patch
4+
"@apollo/gateway": patch
5+
---
6+
7+
Fix potential bug when an `@interfaceObject` type has a `@requires`. When an `@interfaceObject` type has a field with a
8+
`@requires` and the query requests that field only for some specific implementations of the corresponding interface,
9+
then the generated query plan was sometimes invalid and could result in an invalid query to a subgraph (against a
10+
subgraph that rely on `@apollo/subgraph`, this lead the subgraph to produce an error message looking like `"The
11+
_entities resolver tried to load an entity for type X, but no object or interface type of that name was found in the
12+
schema"`).
13+

gateway-js/src/__tests__/executeQueryPlan.test.ts

+245
Original file line numberDiff line numberDiff line change
@@ -4850,6 +4850,251 @@ describe('executeQueryPlan', () => {
48504850
}
48514851
`);
48524852
});
4853+
4854+
test('handles @requires on @interfaceObject that applies to only one of the queried implementation', async () => {
4855+
// The case this test is that where the @interfaceObject in s2 has a @requires, but the query we send requests the field on which
4856+
// there is this @require only for one of the implementation type, which it request another field with no require for another implementation.
4857+
// And we're making sure the requirements only get queried for T1, the first type.
4858+
const s1 = {
4859+
name: 's1',
4860+
typeDefs: gql`
4861+
extend schema
4862+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"])
4863+
4864+
type Query {
4865+
is: [I!]!
4866+
}
4867+
4868+
interface I @key(fields: "id") {
4869+
id: ID!
4870+
name: String
4871+
req: Req
4872+
}
4873+
4874+
type T1 implements I @key(fields: "id") {
4875+
id: ID!
4876+
name: String
4877+
req: Req
4878+
}
4879+
4880+
type T2 implements I @key(fields: "id") {
4881+
id: ID!
4882+
name: String
4883+
req: Req
4884+
}
4885+
4886+
type Req {
4887+
id: ID!
4888+
}
4889+
`,
4890+
resolvers: {
4891+
Query: {
4892+
is: () => [
4893+
{ __typename: 'T1', id: '2', name: 'e2', req: { id: 'r1'} },
4894+
{ __typename: 'T2', id: '4', name: 'e4', req: { id: 'r2'} },
4895+
{ __typename: 'T1', id: '1', name: 'e1', req: { id: 'r3'} }
4896+
]
4897+
},
4898+
}
4899+
}
4900+
4901+
const s2 = {
4902+
name: 's2',
4903+
typeDefs: gql`
4904+
extend schema
4905+
@link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@interfaceObject", "@external", "@requires"])
4906+
4907+
type I @key(fields: "id") @interfaceObject {
4908+
id: ID!
4909+
req: Req @external
4910+
v: String! @requires(fields: "req { id }")
4911+
}
4912+
4913+
type Req {
4914+
id: ID! @external
4915+
}
4916+
`,
4917+
resolvers: {
4918+
I: {
4919+
__resolveReference(ref: any) {
4920+
return {
4921+
...ref,
4922+
v: `req=${ref.req.id}`
4923+
};
4924+
},
4925+
}
4926+
}
4927+
}
4928+
4929+
const { serviceMap, schema, queryPlanner} = getFederatedTestingSchema([ s1, s2 ]);
4930+
4931+
let operation = parseOp(`
4932+
{
4933+
is {
4934+
... on T1 {
4935+
v
4936+
}
4937+
... on T2 {
4938+
name
4939+
}
4940+
}
4941+
}
4942+
`, schema);
4943+
4944+
let queryPlan = buildPlan(operation, queryPlanner);
4945+
expect(queryPlan).toMatchInlineSnapshot(`
4946+
QueryPlan {
4947+
Sequence {
4948+
Fetch(service: "s1") {
4949+
{
4950+
is {
4951+
__typename
4952+
... on T1 {
4953+
__typename
4954+
id
4955+
req {
4956+
id
4957+
}
4958+
}
4959+
... on T2 {
4960+
name
4961+
}
4962+
}
4963+
}
4964+
},
4965+
Flatten(path: "is.@") {
4966+
Fetch(service: "s2") {
4967+
{
4968+
... on T1 {
4969+
__typename
4970+
id
4971+
}
4972+
... on I {
4973+
__typename
4974+
req {
4975+
id
4976+
}
4977+
}
4978+
} =>
4979+
{
4980+
... on I {
4981+
v
4982+
}
4983+
}
4984+
},
4985+
},
4986+
},
4987+
}
4988+
`);
4989+
4990+
let response = await executePlan(queryPlan, operation, undefined, schema, serviceMap);
4991+
expect(response.errors).toBeUndefined();
4992+
expect(response.data).toMatchInlineSnapshot(`
4993+
Object {
4994+
"is": Array [
4995+
Object {
4996+
"v": "req=r1",
4997+
},
4998+
Object {
4999+
"name": "e4",
5000+
},
5001+
Object {
5002+
"v": "req=r3",
5003+
},
5004+
],
5005+
}
5006+
`);
5007+
5008+
// Sanity checking that if we ask for `v` (the field with @requires), then everything still works.
5009+
operation = parseOp(`
5010+
{
5011+
is {
5012+
... on T1 {
5013+
v
5014+
}
5015+
... on T2 {
5016+
v
5017+
name
5018+
}
5019+
}
5020+
}
5021+
`, schema);
5022+
5023+
global.console = require('console');
5024+
queryPlan = buildPlan(operation, queryPlanner);
5025+
expect(queryPlan).toMatchInlineSnapshot(`
5026+
QueryPlan {
5027+
Sequence {
5028+
Fetch(service: "s1") {
5029+
{
5030+
is {
5031+
__typename
5032+
... on T1 {
5033+
__typename
5034+
id
5035+
req {
5036+
id
5037+
}
5038+
}
5039+
... on T2 {
5040+
__typename
5041+
id
5042+
req {
5043+
id
5044+
}
5045+
name
5046+
}
5047+
}
5048+
}
5049+
},
5050+
Flatten(path: "is.@") {
5051+
Fetch(service: "s2") {
5052+
{
5053+
... on T1 {
5054+
__typename
5055+
id
5056+
}
5057+
... on I {
5058+
__typename
5059+
req {
5060+
id
5061+
}
5062+
}
5063+
... on T2 {
5064+
__typename
5065+
id
5066+
}
5067+
} =>
5068+
{
5069+
... on I {
5070+
v
5071+
}
5072+
}
5073+
},
5074+
},
5075+
},
5076+
}
5077+
`);
5078+
5079+
response = await executePlan(queryPlan, operation, undefined, schema, serviceMap);
5080+
expect(response.errors).toBeUndefined();
5081+
expect(response.data).toMatchInlineSnapshot(`
5082+
Object {
5083+
"is": Array [
5084+
Object {
5085+
"v": "req=r1",
5086+
},
5087+
Object {
5088+
"name": "e4",
5089+
"v": "req=r2",
5090+
},
5091+
Object {
5092+
"v": "req=r3",
5093+
},
5094+
],
5095+
}
5096+
`);
5097+
});
48535098
});
48545099

48555100
describe('fields with conflicting types needing aliasing', () => {

internals-js/src/operations.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
} from "./definitions";
5050
import { isInterfaceObjectType } from "./federation";
5151
import { ERRORS } from "./error";
52-
import { isDirectSubtype, sameType } from "./types";
52+
import { isDirectSubtype, isSubtype, sameType } from "./types";
5353
import { assert, mapEntries, MapWithCachedArrays, MultiMap, SetMultiMap } from "./utils";
5454
import { argumentsEquals, argumentsFromAST, isValidValue, valueToAST, valueToString } from "./values";
5555

@@ -568,12 +568,12 @@ function isUselessFollowupElement(first: OperationElement, followup: OperationEl
568568
: first.typeCondition;
569569

570570
// The followup is useless if it's a fragment (with no directives we would want to preserve) whose type
571-
// is already that of the first element.
571+
// is already that of the first element (or a supertype).
572572
return !!typeOfFirst
573573
&& followup.kind === 'FragmentElement'
574574
&& !!followup.typeCondition
575575
&& (followup.appliedDirectives.length === 0 || isDirectiveApplicationsSubset(conditionals, followup.appliedDirectives))
576-
&& sameType(typeOfFirst, followup.typeCondition);
576+
&& isSubtype(followup.typeCondition, typeOfFirst);
577577
}
578578

579579
export type RootOperationPath = {

0 commit comments

Comments
 (0)