Skip to content

Commit e136ad8

Browse files
author
Sylvain Lebresne
authored
Fix possible fragment-related assertion error during query planning. (#2596)
The assertion throw had message of the form `Cannot add fragment of condition X (runtimes: ...) to parent type Y (runtimes: ...)` and was due to not always properly maintaining the "parent type" information when fragment spreads expanding into some other spread. Additionally, this commit fixes a small issue in the code computing the "diff" of what remains in a selection set after a fragment has be "reused", which could lead to inefficient (and weird looking) selections in fetches.
1 parent 5cd17e6 commit e136ad8

4 files changed

Lines changed: 151 additions & 15 deletions

File tree

.changeset/tender-bears-call.md

Lines changed: 9 additions & 0 deletions
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 possible fragment-related assertion error during query planning. This prevents a rare case where an assertion with a
7+
message of the form `Cannot add fragment of condition X (runtimes: ...) to parent type Y (runtimes: ...)` could fail
8+
during query planning.
9+

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,13 @@ describe('fragments optimization', () => {
7171
b: Int
7272
}
7373
74-
type T1 {
74+
type T1 implements I {
7575
a: Int
7676
b: Int
7777
u: U
7878
}
7979
80-
type T2 {
80+
type T2 implements I {
8181
x: String
8282
y: String
8383
b: Int

internals-js/src/operations.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,6 @@ export class NamedFragments {
10471047
dependsOn: string[],
10481048
};
10491049
const fragmentsMap = new Map<string, FragmentInfo>();
1050-
10511050
const removedFragments = new Set<string>();
10521051
for (const fragment of this.definitions()) {
10531052
const mappedSelectionSet = mapper(fragment.selectionSet.expandAllFragments().trimUnsatisfiableBranches(fragment.typeCondition));
@@ -1527,6 +1526,28 @@ export class SelectionSet {
15271526
// validate this (`canApplyAtType` is not free, and we want to avoid repeating it multiple times).
15281527
diffWithNamedFragmentIfContained(candidate: NamedFragmentDefinition, parentType: CompositeType): { contains: boolean, diff?: SelectionSet } {
15291528
const that = candidate.selectionSetAtType(parentType);
1529+
// It's possible that while the fragment technically applies at `parentType`, it's "rebasing" on
1530+
// `parentType` is empty, or contains only `__typename`. For instance, suppose we have
1531+
// a union `U = A | B | C`, and then a fragment:
1532+
// ```graphql
1533+
// fragment F on U {
1534+
// ... on A {
1535+
// x
1536+
// }
1537+
// ... on b {
1538+
// y
1539+
// }
1540+
// }
1541+
// ```
1542+
// It is then possible to apply `F` when the parent type is `C`, but this ends up selecting
1543+
// nothing at all.
1544+
//
1545+
// Returning `contains: true` in those cases is, while not 100% incorrect, at least not productive,
1546+
// and so we skip right away in that case. This is essentially an optimisation.
1547+
if (that.isEmpty() || (that.selections().length === 1 && that.selections()[0].isTypenameField())) {
1548+
return { contains: false };
1549+
}
1550+
15301551
if (this.contains(that)) {
15311552
// One subtlety here is that at "this" sub-selections may already have been optimized with some fragments. It's
15321553
// usually ok because `candidate` will also use those fragments, but one fragments that `candidate` can never be
@@ -1554,6 +1575,17 @@ export class SelectionSet {
15541575
const otherSelections = that.triviallyNestedSelectionsForKey(this.parentType, key);
15551576
const allSelections = thatSelection ? [thatSelection].concat(otherSelections) : otherSelections;
15561577
if (allSelections.length === 0) {
1578+
// If it is a fragment spread, and we didn't find it in `that`, then we try to expand that
1579+
// fragment and see if that result is entirely covered by `that`. If that is the case, then it means
1580+
// `thisSelection` does not need to be in the returned "diff". If it's not entirely covered,
1581+
// we just add the spread itself to the diff: even if some parts of it were covered by `that`,
1582+
// keeping just the fragment is, in a sense, more condensed.
1583+
if (thisSelection instanceof FragmentSpreadSelection) {
1584+
const expanded = thisSelection.selectionSet.expandAllFragments().trimUnsatisfiableBranches(this.parentType);
1585+
if (expanded.minus(that).isEmpty()) {
1586+
continue;
1587+
}
1588+
}
15571589
updated.add(thisSelection);
15581590
} else {
15591591
const selectionDiff = allSelections.reduce<Selection | undefined>((prev, val) => prev?.minus(val), thisSelection);
@@ -2055,6 +2087,11 @@ abstract class AbstractSelection<TElement extends OperationElement, TIsLeaf exte
20552087
return this.element.parentType;
20562088
}
20572089

2090+
isTypenameField(): boolean {
2091+
// Overridden where appropriate
2092+
return false;
2093+
}
2094+
20582095
collectVariables(collector: VariableCollector) {
20592096
this.element.collectVariables(collector);
20602097
this.selectionSet?.collectVariables(collector)
@@ -2181,6 +2218,10 @@ export class FieldSelection extends AbstractSelection<Field<any>, undefined, Fie
21812218
return this;
21822219
}
21832220

2221+
isTypenameField(): boolean {
2222+
return this.element.definition.name === typenameFieldName;
2223+
}
2224+
21842225
withUpdatedComponents(field: Field<any>, selectionSet: SelectionSet | undefined): FieldSelection {
21852226
return new FieldSelection(field, selectionSet);
21862227
}
@@ -2456,7 +2497,6 @@ export abstract class FragmentSelection extends AbstractSelection<FragmentElemen
24562497
|| (isObjectType(parentType) && possibleRuntimeTypes(this.element.typeCondition).some((t) => t.name === parentType.name))
24572498
);
24582499
}
2459-
24602500
}
24612501

24622502
class InlineFragmentSelection extends FragmentSelection {
@@ -2668,7 +2708,7 @@ class InlineFragmentSelection extends FragmentSelection {
26682708
if (isObjectType(thisCondition) || !possibleRuntimeTypes(thisCondition).includes(currentType)) {
26692709
return undefined;
26702710
} else {
2671-
const trimmed =this.selectionSet.trimUnsatisfiableBranches(currentType, options);
2711+
const trimmed = this.selectionSet.trimUnsatisfiableBranches(currentType, options);
26722712
return trimmed.isEmpty() ? undefined : trimmed;
26732713
}
26742714
}
@@ -2799,8 +2839,10 @@ class FragmentSpreadSelection extends FragmentSelection {
27992839
assert(false, `Unsupported`);
28002840
}
28012841

2802-
trimUnsatisfiableBranches(_: CompositeType): FragmentSelection {
2803-
return this;
2842+
trimUnsatisfiableBranches(parentType: CompositeType): FragmentSelection {
2843+
// We must update the spread parent type if necessary since we're not going deeper,
2844+
// or we'll be fundamentally losing context.
2845+
return this.rebaseOn(parentType);
28042846
}
28052847

28062848
namedFragments(): NamedFragments | undefined {
@@ -2837,18 +2879,30 @@ class FragmentSpreadSelection extends FragmentSelection {
28372879
return this;
28382880
}
28392881

2840-
rebaseOn(_parentType: CompositeType): FragmentSelection {
2841-
// This is a little bit iffy, because the fragment could link to a schema (typically the supergraph API one)
2842-
// that is different from the one of `_selectionSet` (say, a subgraph fetch selection in which we're trying to
2843-
// reuse a user fragment). But in practice, we expand all fragments when we do query planning and only re-add
2844-
// fragments back at the very end, so this should be fine. Importantly, we don't want this method to mistakenly
2845-
// expand the spread, as that would compromise the code that optimize subgraph fetches to re-use named
2882+
rebaseOn(parentType: CompositeType): FragmentSelection {
2883+
// We preserve the parent type here, to make sure we don't lose context, but we actually don't
2884+
// want to expand the spread as that would compromise the code that optimize subgraph fetches to re-use named
28462885
// fragments.
2847-
return this;
2886+
//
2887+
// This is a little bit iffy, because the fragment may not apply at this parent type, but we
2888+
// currently leave it to the caller to ensure this is not a mistake. But most of the
2889+
// QP code works on selections with fully expanded fragments, so this code (and that of `canAddTo`
2890+
// on come into play in the code for reusing fragments, and that code calls those methods
2891+
// appropriately.
2892+
if (this.parentType === parentType) {
2893+
return this;
2894+
}
2895+
return new FragmentSpreadSelection(
2896+
parentType,
2897+
this.fragments,
2898+
this.namedFragment,
2899+
this.spreadDirectives,
2900+
);
28482901
}
28492902

28502903
canAddTo(_: CompositeType): boolean {
2851-
// Mimicking the logic of `rebaseOn`.
2904+
// Since `rebaseOn` never fail, we copy the logic here and always return `true`. But as
2905+
// mentioned in `rebaseOn`, this leave it a bit to the caller to know what he is doing.
28522906
return true;
28532907
}
28542908

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5748,3 +5748,76 @@ test('does not error out handling fragments when interface subtyping is involved
57485748
}
57495749
`);
57505750
});
5751+
5752+
5753+
test('handles mix of fragments indirection and unions', () => {
5754+
const subgraph1 = {
5755+
name: 'Subgraph1',
5756+
typeDefs: gql`
5757+
type Query {
5758+
parent: Parent
5759+
}
5760+
5761+
union CatOrPerson = Cat | Parent | Child
5762+
5763+
type Parent {
5764+
childs: [Child]
5765+
}
5766+
5767+
type Child {
5768+
id: ID!
5769+
}
5770+
5771+
type Cat {
5772+
name: String
5773+
}
5774+
`
5775+
}
5776+
5777+
const [api, queryPlanner] = composeAndCreatePlanner(subgraph1);
5778+
const operation = operationFromDocument(api, gql`
5779+
query {
5780+
parent {
5781+
...F_indirection1_parent
5782+
}
5783+
}
5784+
5785+
fragment F_indirection1_parent on Parent {
5786+
...F_indirection2_catOrPerson
5787+
}
5788+
5789+
fragment F_indirection2_catOrPerson on CatOrPerson {
5790+
...F_catOrPerson
5791+
}
5792+
5793+
fragment F_catOrPerson on CatOrPerson {
5794+
__typename
5795+
... on Cat {
5796+
name
5797+
}
5798+
... on Parent {
5799+
childs {
5800+
__typename
5801+
id
5802+
}
5803+
}
5804+
}
5805+
`);
5806+
5807+
const plan = queryPlanner.buildQueryPlan(operation);
5808+
expect(plan).toMatchInlineSnapshot(`
5809+
QueryPlan {
5810+
Fetch(service: "Subgraph1") {
5811+
{
5812+
parent {
5813+
__typename
5814+
childs {
5815+
__typename
5816+
id
5817+
}
5818+
}
5819+
}
5820+
},
5821+
}
5822+
`);
5823+
});

0 commit comments

Comments
 (0)