Skip to content

Commit 179b460

Browse files
author
Sylvain Lebresne
authored
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 449351d commit 179b460

File tree

4 files changed

+299
-50
lines changed

4 files changed

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

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

internals-js/src/operations.ts

+14-4
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 { sameType } from "./types";
52+
import { isSubtype, sameType } from "./types";
5353
import { assert, isDefined, mapEntries, mapValues, MapWithCachedArrays, MultiMap, SetMultiMap } from "./utils";
5454
import { argumentsEquals, argumentsFromAST, isValidValue, valueToAST, valueToString } from "./values";
5555
import { v1 as uuidv1 } from 'uuid';
@@ -678,12 +678,12 @@ function isUselessFollowupElement(first: OperationElement, followup: OperationEl
678678
: first.typeCondition;
679679

680680
// The followup is useless if it's a fragment (with no directives we would want to preserve) whose type
681-
// is already that of the first element.
681+
// is already that of the first element (or a supertype).
682682
return !!typeOfFirst
683683
&& followup.kind === 'FragmentElement'
684684
&& !!followup.typeCondition
685685
&& (followup.appliedDirectives.length === 0 || isDirectiveApplicationsSubset(conditionals, followup.appliedDirectives))
686-
&& sameType(typeOfFirst, followup.typeCondition);
686+
&& isSubtype(followup.typeCondition, typeOfFirst);
687687
}
688688

689689
export type RootOperationPath = {
@@ -1598,9 +1598,19 @@ function addOneToKeyedUpdates(keyedUpdates: MultiMap<string, SelectionUpdate>, s
15981598
}
15991599
}
16001600

1601+
function maybeRebaseOnSchema(toRebase: CompositeType, schema: Schema): CompositeType {
1602+
if (toRebase.schema() === schema) {
1603+
return toRebase;
1604+
}
1605+
1606+
const rebased = schema.type(toRebase.name);
1607+
assert(rebased && isCompositeType(rebased), () => `Expected ${toRebase} to exists and be composite in the rebased schema, but got ${rebased?.kind}`);
1608+
return rebased;
1609+
}
1610+
16011611
function isUnecessaryFragment(parentType: CompositeType, fragment: FragmentSelection): boolean {
16021612
return fragment.element.appliedDirectives.length === 0
1603-
&& (!fragment.element.typeCondition || sameType(parentType, fragment.element.typeCondition));
1613+
&& (!fragment.element.typeCondition || isSubtype(maybeRebaseOnSchema(fragment.element.typeCondition, parentType.schema()), parentType));
16041614
}
16051615

16061616
function withUnecessaryFragmentsRemoved(

0 commit comments

Comments
 (0)