Skip to content

Commit 5cd17e6

Browse files
author
Sylvain Lebresne
authored
Fix assertion error with overlapping fragments and subtyping (#2594)
When checking for name fragments to reuse, when some fields implementing interfaces use subtyping (meaning that the type of the field implementation is a sub-type of the type declared for that field in the interface), an assertion error of the form `Cannot add selection of field X to selection set of parent type Y` was sometimes raised (see the comments in the commit for mode details). Fixes #2592.
1 parent e874a43 commit 5cd17e6

File tree

4 files changed

+224
-8
lines changed

4 files changed

+224
-8
lines changed

.changeset/brave-dryers-drop.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@apollo/query-planner": patch
3+
"@apollo/federation-internals": patch
4+
---
5+
6+
Fix assertion error in some overlapping fragment cases. In some cases, when fragments overlaps on some sub-selections
7+
and some interface field implementation relied on sub-typing, an assertion error could be raised with a message of
8+
the form `Cannot add selection of field X to selection set of parent type Y` and this fixes this problem.
9+

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

+61-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {
2+
CompositeType,
23
defaultRootName,
34
Schema,
45
SchemaRootKind,
56
} from '../../dist/definitions';
67
import { buildSchema } from '../../dist/buildSchema';
7-
import { MutableSelectionSet, Operation, operationFromDocument, parseOperation } from '../../dist/operations';
8+
import { MutableSelectionSet, Operation, operationFromDocument, parseOperation, parseSelectionSet } from '../../dist/operations';
89
import './matchers';
910
import { DocumentNode, FieldNode, GraphQLError, Kind, OperationDefinitionNode, OperationTypeNode, SelectionNode, SelectionSetNode } from 'graphql';
1011

@@ -1264,3 +1265,62 @@ describe('unsatisfiable branches removal', () => {
12641265
expect(withoutUnsatisfiableBranches(input)).toBe(output);
12651266
});
12661267
});
1268+
1269+
test('contains ignores unecessary fragments even when subtyping is involved', () => {
1270+
const schema = parseSchema(`
1271+
type Query {
1272+
a: A!
1273+
}
1274+
1275+
interface IA1 {
1276+
b: IB1!
1277+
}
1278+
1279+
interface IA2 {
1280+
b: IB2!
1281+
}
1282+
1283+
type A implements IA1 & IA2 {
1284+
b: B!
1285+
}
1286+
1287+
interface IB1 {
1288+
v1: Int!
1289+
}
1290+
1291+
interface IB2 {
1292+
v2: Int!
1293+
}
1294+
1295+
type B implements IB1 & IB2 {
1296+
v1: Int!
1297+
v2: Int!
1298+
}
1299+
`);
1300+
1301+
const typeA = schema.type('A') as CompositeType;
1302+
1303+
const s1 = parseSelectionSet({
1304+
parentType: typeA,
1305+
source: '{ b { v1 v2 } }'
1306+
});
1307+
1308+
const s2 = parseSelectionSet({
1309+
parentType: typeA,
1310+
source: '{ ... on IA1 { b { v1 } } ... on IA2 { b { v2 } } }'
1311+
});
1312+
1313+
// Here, A is a concrete type, and IA1 and IA2 are just 2 of its interfaces, so
1314+
// a { ... on IA1 { b { v1 } } ... on IA2 { b { v2 } } }
1315+
// is basically exactly the same as:
1316+
// a { b { v1 } b { v2 } }
1317+
// which is the same as:
1318+
// a { b { v1 v2 } }
1319+
// and that is why we want the `contains` below to work (note that a simple `contains`
1320+
// that doesn't handle this kind of subtlety could have its use too, it would just be
1321+
// a different contract, but we want "our" `contains` to handle this because of named
1322+
// fragments "reconstruction" where that kind of subtlety arises).
1323+
//
1324+
// Here, the added subtlety is that there is interface subtyping involved too.
1325+
expect(s2.contains(s1)).toBeTruthy();
1326+
});

internals-js/src/operations.ts

+78-7
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
isLeafType,
4747
Variables,
4848
isObjectType,
49+
NamedType,
4950
} from "./definitions";
5051
import { isInterfaceObjectType } from "./federation";
5152
import { ERRORS } from "./error";
@@ -152,7 +153,11 @@ export class Field<TArgs extends {[key: string]: any} = {[key: string]: any}> ex
152153
}
153154

154155
isLeafField(): boolean {
155-
return isLeafType(baseType(this.definition.type!));
156+
return isLeafType(this.baseType());
157+
}
158+
159+
baseType(): NamedType {
160+
return baseType(this.definition.type!);
156161
}
157162

158163
withUpdatedDefinition(newDefinition: FieldDefinition<any>): Field<TArgs> {
@@ -674,7 +679,7 @@ export function concatOperationPaths(head: OperationPath, tail: OperationPath):
674679

675680
function isUselessFollowupElement(first: OperationElement, followup: OperationElement, conditionals: Directive<any, any>[]): boolean {
676681
const typeOfFirst = first.kind === 'Field'
677-
? baseType(first.definition.type!)
682+
? first.baseType()
678683
: first.typeCondition;
679684

680685
// The followup is useless if it's a fragment (with no directives we would want to preserve) whose type
@@ -1430,7 +1435,73 @@ export class SelectionSet {
14301435
for (const selection of selections) {
14311436
mergedSubselections.add(selection.selectionSet!);
14321437
}
1433-
return first.withUpdatedSelectionSet(mergedSubselections.toSelectionSet(first.selectionSet.parentType));
1438+
1439+
// We know all the `selections` are basically for the same element (same field or same inline fragment),
1440+
// and we want to return a single selection with the merged selections. There is a subtlety regarding
1441+
// the parent type of that merged selection however: we cannot safely rely on the parent type of any
1442+
// of the individual selections, because this can be incorrect. Let's illustrate.
1443+
// Consider that we have:
1444+
// ```graphql
1445+
// type Query {
1446+
// a: A!
1447+
// }
1448+
//
1449+
// interface IA1 {
1450+
// b: IB1!
1451+
// }
1452+
//
1453+
// interface IA2 {
1454+
// b: IB2!
1455+
// }
1456+
//
1457+
// type A implements IA1 & IA2 {
1458+
// b: B!
1459+
// }
1460+
//
1461+
// interface IB1 {
1462+
// v1: Int!
1463+
// }
1464+
//
1465+
// interface IB2 {
1466+
// v2: Int!
1467+
// }
1468+
//
1469+
// type B implements IB1 & IB2 {
1470+
// v1: Int!
1471+
// v2: Int!
1472+
// }
1473+
// ```
1474+
// and suppose that we're trying to check if selection set:
1475+
// maybeSuperset = { ... on IA1 { b { v1 } } ... on IA2 { b { v2 } } } // (parent type A)
1476+
// contains selection set:
1477+
// maybeSubset = { b { v1 v2 } } // (parent type A)
1478+
//
1479+
// In that case, the `contains` method below will call this function with the 2 sub-selections
1480+
// from `maybeSuperset`, but with the unecessary interface fragment removed (reminder that the
1481+
// parent type is `A`, so the "casts" into the interfaces are semantically useless).
1482+
//
1483+
// And so in that case, the argument to this method will be:
1484+
// [ b { v1 } (parent type IA1), b { v2 } (parent type IA2) ]
1485+
// but then, the sub-selection `{ v1 }` of the 1st value will have parent type IB1,
1486+
// and the sub-selection `{ v2 }` of the 2nd value will have parent type IB2,
1487+
// neither of which work for the merge sub-selection.
1488+
//
1489+
// Instead, we want to use as parent type the type of field `b` the parent type of `this`
1490+
// (which is `maybeSupeset` in our example). Which means that we want to use type `B` for
1491+
// the sub-selection, which is now guaranteed to work (or `maybeSupergerset` wouldn't have
1492+
// been valid).
1493+
//
1494+
// Long story short, we get that type by rebasing any of the selection element (we use the
1495+
// first as we have it) on `this.parentType`, which gives use the element we want, and we
1496+
// use the type of that for the sub-selection.
1497+
1498+
if (first.kind === 'FieldSelection') {
1499+
const rebasedField = first.element.rebaseOn(this.parentType);
1500+
return new FieldSelection(rebasedField, mergedSubselections.toSelectionSet(rebasedField.baseType() as CompositeType));
1501+
} else {
1502+
const rebasedFragment = first.element.rebaseOn(this.parentType);
1503+
return new InlineFragmentSelection(rebasedFragment, mergedSubselections.toSelectionSet(rebasedFragment.castedType()));
1504+
}
14341505
}
14351506

14361507
contains(that: SelectionSet): boolean {
@@ -1790,7 +1861,7 @@ function makeSelection(parentType: CompositeType, updates: SelectionUpdate[], fr
17901861
}
17911862

17921863
const element = updateElement(first).rebaseOn(parentType);
1793-
const subSelectionParentType = element.kind === 'Field' ? baseType(element.definition.type!) : element.castedType();
1864+
const subSelectionParentType = element.kind === 'Field' ? element.baseType() : element.castedType();
17941865
if (!isCompositeType(subSelectionParentType)) {
17951866
// This is a leaf, so all updates should correspond ot the same field and we just use the first.
17961867
return selectionOfElement(element);
@@ -2120,7 +2191,7 @@ export class FieldSelection extends AbstractSelection<Field<any>, undefined, Fie
21202191

21212192
optimize(fragments: NamedFragments): Selection {
21222193
let optimizedSelection = this.selectionSet ? this.selectionSet.optimizeSelections(fragments) : undefined;
2123-
const fieldBaseType = baseType(this.element.definition.type!);
2194+
const fieldBaseType = this.element.baseType();
21242195
if (isCompositeType(fieldBaseType) && optimizedSelection) {
21252196
const optimized = this.tryOptimizeSubselectionWithFragments({
21262197
parentType: fieldBaseType,
@@ -2213,7 +2284,7 @@ export class FieldSelection extends AbstractSelection<Field<any>, undefined, Fie
22132284
return this.withUpdatedElement(rebasedElement);
22142285
}
22152286

2216-
const rebasedBase = baseType(rebasedElement.definition.type!);
2287+
const rebasedBase = rebasedElement.baseType();
22172288
if (rebasedBase === this.selectionSet.parentType) {
22182289
return this.withUpdatedElement(rebasedElement);
22192290
}
@@ -2279,7 +2350,7 @@ export class FieldSelection extends AbstractSelection<Field<any>, undefined, Fie
22792350
return this;
22802351
}
22812352

2282-
const base = baseType(this.element.definition.type!)
2353+
const base = this.element.baseType()
22832354
assert(isCompositeType(base), () => `Field ${this.element} should not have a sub-selection`);
22842355
const trimmed = (options?.recursive ?? true) ? this.mapToSelectionSet((s) => s.trimUnsatisfiableBranches(base)) : this;
22852356
// In rare caes, it's possible that everything in the sub-selection was trimmed away and so the

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

+76
Original file line numberDiff line numberDiff line change
@@ -5672,3 +5672,79 @@ test('handles types with no common supertype at the same "mergeAt"', () => {
56725672
}
56735673
`);
56745674
});
5675+
5676+
test('does not error out handling fragments when interface subtyping is involved', () => {
5677+
// This test essentially make sure the issue in https://github.com/apollographql/federation/issues/2592
5678+
// is resolved.
5679+
const subgraph1 = {
5680+
name: 'Subgraph1',
5681+
typeDefs: gql`
5682+
type Query {
5683+
a: A!
5684+
}
5685+
5686+
interface IA {
5687+
b: IB!
5688+
}
5689+
5690+
type A implements IA {
5691+
b: B!
5692+
}
5693+
5694+
interface IB {
5695+
v1: Int!
5696+
}
5697+
5698+
type B implements IB {
5699+
v1: Int!
5700+
v2: Int!
5701+
}
5702+
`
5703+
}
5704+
5705+
const [api, queryPlanner] = composeAndCreatePlanner(subgraph1);
5706+
const operation = operationFromDocument(api, gql`
5707+
{
5708+
a {
5709+
...F1
5710+
...F2
5711+
...F3
5712+
}
5713+
}
5714+
5715+
fragment F1 on A {
5716+
b {
5717+
v2
5718+
}
5719+
}
5720+
5721+
fragment F2 on IA {
5722+
b {
5723+
v1
5724+
}
5725+
}
5726+
5727+
fragment F3 on IA {
5728+
b {
5729+
__typename
5730+
}
5731+
}
5732+
`);
5733+
5734+
const plan = queryPlanner.buildQueryPlan(operation);
5735+
expect(plan).toMatchInlineSnapshot(`
5736+
QueryPlan {
5737+
Fetch(service: "Subgraph1") {
5738+
{
5739+
a {
5740+
b {
5741+
__typename
5742+
v1
5743+
v2
5744+
}
5745+
}
5746+
}
5747+
},
5748+
}
5749+
`);
5750+
});

0 commit comments

Comments
 (0)