Skip to content

Commit ec04c50

Browse files
authored
Fix a query planning bug where invalid subgraph queries are generated with reuseQueryFragments set true (#2963)
Fixed #2952. ### Summary of changes - updated computeExpandedSelectionSetAtType function not to trim fragment's validators if the fragment's type condition is not an object type. - This change is necessary because `FieldsInSetCanMerge` rule is more restrictive in that case. - The untrimmed validator should avoid generating invalid queries, but it may be less efficient. ### Test plan - The operations.test.ts confirms the changes of named fragments' validators. - Added a new test (based on the github issue's reproducer) confirming that subgraph queries are no longer invalid.
1 parent a494631 commit ec04c50

File tree

4 files changed

+241
-3
lines changed

4 files changed

+241
-3
lines changed

.changeset/invalid-reused-fragment.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/query-planner": patch
3+
"@apollo/composition": patch
4+
"@apollo/federation-internals": patch
5+
---
6+
7+
Fix a query planning bug where invalid subgraph queries are generated with `reuseQueryFragments` set true. ([#2952](https://github.com/apollographql/federation/issues/2952))

internals-js/src/__tests__/operations.test.ts

+66-1
Original file line numberDiff line numberDiff line change
@@ -3100,6 +3100,38 @@ describe('named fragment selection set restrictions at type', () => {
31003100
return frag.expandedSelectionSetAtType(type);
31013101
}
31023102

3103+
test('for fragment on object types', () => {
3104+
const schema = parseSchema(`
3105+
type Query {
3106+
t1: T1
3107+
}
3108+
3109+
type T1 {
3110+
x: Int
3111+
y: Int
3112+
}
3113+
`);
3114+
3115+
const operation = parseOperation(schema, `
3116+
{
3117+
t1 {
3118+
...FonT1
3119+
}
3120+
}
3121+
3122+
fragment FonT1 on T1 {
3123+
x
3124+
y
3125+
}
3126+
`);
3127+
3128+
const frag = operation.fragments?.get('FonT1')!;
3129+
3130+
const { selectionSet, validator } = expandAtType(frag, schema, 'T1');
3131+
expect(selectionSet.toString()).toBe('{ x y }');
3132+
expect(validator?.toString()).toBeUndefined();
3133+
});
3134+
31033135
test('for fragment on interfaces', () => {
31043136
const schema = parseSchema(`
31053137
type Query {
@@ -3159,17 +3191,32 @@ describe('named fragment selection set restrictions at type', () => {
31593191

31603192
let { selectionSet, validator } = expandAtType(frag, schema, 'I1');
31613193
expect(selectionSet.toString()).toBe('{ x ... on T1 { x } ... on T2 { x } ... on I2 { x } ... on I3 { x } }');
3162-
expect(validator?.toString()).toBeUndefined();
3194+
// Note: Due to `FieldsInSetCanMerge` rule, we can't use trimmed validators for
3195+
// fragments on non-object types.
3196+
expect(validator?.toString()).toMatchString(`
3197+
{
3198+
x: [
3199+
I1.x
3200+
T1.x
3201+
T2.x
3202+
I2.x
3203+
I3.x
3204+
]
3205+
}
3206+
`);
31633207

31643208
({ selectionSet, validator } = expandAtType(frag, schema, 'T1'));
31653209
expect(selectionSet.toString()).toBe('{ x }');
3210+
// Note: Fragments on non-object types always have the full validator and thus
3211+
// results in the same validator regardless of the type they are expanded at.
31663212
// Note: one could remark that having `T1.x` in the `validator` below is a tad weird: this is
31673213
// because in this case `normalized` removed that fragment and so when we do `normalized.minus(original)`,
31683214
// then it shows up. It a tad difficult to avoid this however and it's ok for what we do (`validator`
31693215
// is used to check for field conflict and save for efficiency, we could use the `original` instead).
31703216
expect(validator?.toString()).toMatchString(`
31713217
{
31723218
x: [
3219+
I1.x
31733220
T1.x
31743221
T2.x
31753222
I2.x
@@ -3239,8 +3286,16 @@ describe('named fragment selection set restrictions at type', () => {
32393286
// this happens due to the "lifting" of selection mentioned above, is a bit hard to avoid,
32403287
// and is essentially harmess (it may result in a bit more cpu cycles in some cases but
32413288
// that is likely negligible).
3289+
// Note: Due to `FieldsInSetCanMerge` rule, we can't use trimmed validators for
3290+
// fragments on non-object types.
32423291
expect(validator?.toString()).toMatchString(`
32433292
{
3293+
x: [
3294+
T1.x
3295+
]
3296+
z: [
3297+
T2.z
3298+
]
32443299
y: [
32453300
T1.y
32463301
]
@@ -3252,8 +3307,13 @@ describe('named fragment selection set restrictions at type', () => {
32523307

32533308
({ selectionSet, validator } = expandAtType(frag, schema, 'U2'));
32543309
expect(selectionSet.toString()).toBe('{ ... on T1 { x y } }');
3310+
// Note: Fragments on non-object types always have the full validator and thus
3311+
// results in the same validator regardless of the type they are expanded at.
32553312
expect(validator?.toString()).toMatchString(`
32563313
{
3314+
x: [
3315+
T1.x
3316+
]
32573317
z: [
32583318
T2.z
32593319
]
@@ -3268,11 +3328,16 @@ describe('named fragment selection set restrictions at type', () => {
32683328

32693329
({ selectionSet, validator } = expandAtType(frag, schema, 'U3'));
32703330
expect(selectionSet.toString()).toBe('{ ... on T2 { z w } }');
3331+
// Note: Fragments on non-object types always have the full validator and thus
3332+
// results in the same validator regardless of the type they are expanded at.
32713333
expect(validator?.toString()).toMatchString(`
32723334
{
32733335
x: [
32743336
T1.x
32753337
]
3338+
z: [
3339+
T2.z
3340+
]
32763341
y: [
32773342
T1.y
32783343
]

internals-js/src/operations.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,14 @@ export class NamedFragmentDefinition extends DirectiveTargetElement<NamedFragmen
12241224
const expandedSelectionSet = this.expandedSelectionSet();
12251225
const selectionSet = expandedSelectionSet.normalize({ parentType: type });
12261226

1227+
if (!isObjectType(this.typeCondition)) {
1228+
// When the type condition of the fragment is not an object type, the `FieldsInSetCanMerge` rule is more
1229+
// restrictive and any fields can create conflicts. Thus, we have to use the full validator in this case.
1230+
// (see https://github.com/graphql/graphql-spec/issues/1085 for details.)
1231+
const validator = FieldsConflictValidator.build(expandedSelectionSet);
1232+
return { selectionSet, validator };
1233+
}
1234+
12271235
// Note that `trimmed` is the difference of 2 selections that may not have been normalized on the same parent type,
12281236
// so in practice, it is possible that `trimmed` contains some of the selections that `selectionSet` contains, but
12291237
// that they have been simplified in `selectionSet` in such a way that the `minus` call does not see it. However,
@@ -2880,7 +2888,7 @@ class FieldsConflictValidator {
28802888
continue;
28812889
}
28822890

2883-
// We're basically checking [FieldInSetCanMerge](https://spec.graphql.org/draft/#FieldsInSetCanMerge()),
2891+
// We're basically checking [FieldsInSetCanMerge](https://spec.graphql.org/draft/#FieldsInSetCanMerge()),
28842892
// but from 2 set of fields (`thisFields` and `thatFields`) of the same response that we know individually
28852893
// merge already.
28862894
for (const [thisField, thisValidator] of thisFields.entries()) {

query-planner-js/src/__tests__/buildPlan.test.ts

+159-1
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@ import {
44
operationFromDocument,
55
ServiceDefinition,
66
Supergraph,
7+
buildSubgraph,
78
} from '@apollo/federation-internals';
89
import gql from 'graphql-tag';
910
import {
1011
FetchNode,
1112
FlattenNode,
13+
PlanNode,
1214
SequenceNode,
15+
SubscriptionNode,
1316
serializeQueryPlan,
1417
} from '../QueryPlan';
15-
import { FieldNode, OperationDefinitionNode, parse } from 'graphql';
18+
import {
19+
FieldNode,
20+
OperationDefinitionNode,
21+
parse,
22+
validate,
23+
GraphQLError,
24+
} from 'graphql';
1625
import {
1726
composeAndCreatePlanner,
1827
composeAndCreatePlannerWithOptions,
@@ -5041,8 +5050,157 @@ describe('Named fragments preservation', () => {
50415050
}
50425051
`);
50435052
});
5053+
5054+
it('validates fragments on non-object types across spreads', () => {
5055+
const subgraph1 = {
5056+
name: 'subgraph1',
5057+
typeDefs: gql`
5058+
type Query {
5059+
theEntity: AnyEntity
5060+
}
5061+
5062+
union AnyEntity = EntityTypeA | EntityTypeB
5063+
5064+
type EntityTypeA @key(fields: "unifiedEntityId") {
5065+
unifiedEntityId: ID!
5066+
unifiedEntity: UnifiedEntity
5067+
}
5068+
5069+
type EntityTypeB @key(fields: "unifiedEntityId") {
5070+
unifiedEntityId: ID!
5071+
unifiedEntity: UnifiedEntity
5072+
}
5073+
5074+
interface UnifiedEntity {
5075+
id: ID!
5076+
}
5077+
5078+
type Generic implements UnifiedEntity @key(fields: "id") {
5079+
id: ID!
5080+
}
5081+
5082+
type Movie implements UnifiedEntity @key(fields: "id") {
5083+
id: ID!
5084+
}
5085+
5086+
type Show implements UnifiedEntity @key(fields: "id") {
5087+
id: ID!
5088+
}
5089+
`,
5090+
};
5091+
5092+
const subgraph2 = {
5093+
name: 'subgraph2',
5094+
typeDefs: gql`
5095+
interface Video {
5096+
videoId: Int!
5097+
taglineMessage(uiContext: String): String
5098+
}
5099+
5100+
interface UnifiedEntity {
5101+
id: ID!
5102+
}
5103+
5104+
type Generic implements UnifiedEntity @key(fields: "id") {
5105+
id: ID!
5106+
taglineMessage(uiContext: String): String
5107+
}
5108+
5109+
type Movie implements UnifiedEntity & Video @key(fields: "id") {
5110+
videoId: Int!
5111+
id: ID!
5112+
taglineMessage(uiContext: String): String
5113+
}
5114+
5115+
type Show implements UnifiedEntity & Video @key(fields: "id") {
5116+
videoId: Int!
5117+
id: ID!
5118+
taglineMessage(uiContext: String): String
5119+
}
5120+
`,
5121+
};
5122+
5123+
const [api, queryPlanner] = composeAndCreatePlannerWithOptions(
5124+
[subgraph1, subgraph2],
5125+
{ reuseQueryFragments: true },
5126+
);
5127+
5128+
const query = gql`
5129+
query Search {
5130+
theEntity {
5131+
... on EntityTypeA {
5132+
unifiedEntity {
5133+
... on Generic {
5134+
taglineMessage(uiContext: "Generic")
5135+
}
5136+
}
5137+
}
5138+
... on EntityTypeB {
5139+
unifiedEntity {
5140+
...VideoSummary
5141+
}
5142+
}
5143+
}
5144+
}
5145+
5146+
fragment VideoSummary on Video {
5147+
videoId # Note: This extra field selection is necessary, so this fragment is not ignored.
5148+
taglineMessage(uiContext: "Video")
5149+
}
5150+
`;
5151+
const operation = operationFromDocument(api, query, { validate: true });
5152+
5153+
const plan = queryPlanner.buildQueryPlan(operation);
5154+
const validationErrors = validateSubFetches(plan.node, subgraph2);
5155+
expect(validationErrors).toHaveLength(0);
5156+
});
50445157
});
50455158

5159+
/**
5160+
* For each fetch in a PlanNode validate the generated operation is actually spec valid against its subgraph schema
5161+
* @param plan
5162+
* @param subgraphs
5163+
*/
5164+
function validateSubFetches(
5165+
plan: PlanNode | SubscriptionNode | undefined,
5166+
subgraphDef: ServiceDefinition,
5167+
): {
5168+
errors: readonly GraphQLError[];
5169+
serviceName: string;
5170+
fetchNode: FetchNode;
5171+
}[] {
5172+
if (!plan) {
5173+
return [];
5174+
}
5175+
const fetches = findFetchNodes(subgraphDef.name, plan);
5176+
const results: {
5177+
errors: readonly GraphQLError[];
5178+
serviceName: string;
5179+
fetchNode: FetchNode;
5180+
}[] = [];
5181+
for (const fetch of fetches) {
5182+
const subgraphName: string = fetch.serviceName;
5183+
const operation: string = fetch.operation;
5184+
const subgraph = buildSubgraph(
5185+
subgraphName,
5186+
'http://subgraph',
5187+
subgraphDef.typeDefs,
5188+
);
5189+
const gql_errors = validate(
5190+
subgraph.schema.toGraphQLJSSchema(),
5191+
parse(operation),
5192+
);
5193+
if (gql_errors.length > 0) {
5194+
results.push({
5195+
errors: gql_errors,
5196+
serviceName: subgraphName,
5197+
fetchNode: fetch,
5198+
});
5199+
}
5200+
}
5201+
return results;
5202+
}
5203+
50465204
describe('Fragment autogeneration', () => {
50475205
const subgraph = {
50485206
name: 'Subgraph1',

0 commit comments

Comments
 (0)