Skip to content

Commit c6e0e76

Browse files
author
Sylvain Lebresne
authored
Fix potential normalization assertion error for fragment on abstract types (#2725)
An abstract type (interface or union) can have different runtime types in different subgraphs. For instance, a type `T` may implement some interface `I` in subgraph A, but not in subgraph B _even_ if `I` is otherwise defined in subgraph B (admittedly something not to be abused, but it can be convenient as a temporary state when evolving schema). For named fragment, this can create a case where a fragment on an abstract type can be "rebased" on a subgraph, but where some of its application (spread) in other fragments are invalid due to being use in the context of a type that intersect the abstrat type in the supergraph but not in the particular subgraph. When that's the case, the invalid spread (for the subgraph) needs to be expanded, and that expansion properly "rebased", but the code was not handling that case at all. Instead it kept the invalid spread, and this led to some later assertion errors. Fixes #2721.
1 parent 6f1fddb commit c6e0e76

File tree

4 files changed

+546
-6
lines changed

4 files changed

+546
-6
lines changed

.changeset/nice-seahorses-cheer.md

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@apollo/query-planner": patch
3+
"@apollo/federation-internals": patch
4+
---
5+
6+
Fix potential assertion error for named fragment on abstract types when the abstract type does not have the same
7+
possible runtime types in all subgraphs.
8+
9+
The error manifested itself during query planning with an error message of the form `Cannot normalize X at Y ...`.
10+

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

+1-3
Original file line numberDiff line numberDiff line change
@@ -3542,9 +3542,7 @@ describe('named fragment rebasing on subgraphs', () => {
35423542
t {
35433543
x
35443544
y
3545-
... on T {
3546-
z
3547-
}
3545+
z
35483546
}
35493547
}
35503548
`);

internals-js/src/operations.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -1406,7 +1406,10 @@ export class NamedFragments {
14061406
return undefined;
14071407
}
14081408

1409-
const rebasedSelection = fragment.selectionSet.rebaseOn({ parentType: rebasedType, fragments: newFragments, errorIfCannotRebase: false });
1409+
let rebasedSelection = fragment.selectionSet.rebaseOn({ parentType: rebasedType, fragments: newFragments, errorIfCannotRebase: false });
1410+
// Rebasing can leave some inefficiencies in some case (particularly when a spread has to be "expanded", see `FragmentSpreadSelection.rebaseOn`),
1411+
// so we do a top-level normalization to keep things clean.
1412+
rebasedSelection = rebasedSelection.normalize({ parentType: rebasedType });
14101413
return this.selectionSetIsWorthUsing(rebasedSelection)
14111414
? new NamedFragmentDefinition(schema, fragment.name, rebasedType).setSelectionSet(rebasedSelection)
14121415
: undefined;
@@ -1421,9 +1424,11 @@ export class NamedFragments {
14211424
// dependency order, we know that `newFragments` will have every fragments that should be
14221425
// kept/not expanded.
14231426
const updatedSelectionSet = fragment.selectionSet.expandFragments(newFragments);
1427+
// Note that if we did expanded some fragments (the updated selection is not the original one), then the
1428+
// results may not be fully normalized, so we do it to be sure.
14241429
return updatedSelectionSet === fragment.selectionSet
14251430
? fragment
1426-
: fragment.withUpdatedSelectionSet(updatedSelectionSet);
1431+
: fragment.withUpdatedSelectionSet(updatedSelectionSet.normalize({ parentType: updatedSelectionSet.parentType}));
14271432
} else {
14281433
return undefined;
14291434
}
@@ -3558,7 +3563,8 @@ class FragmentSpreadSelection extends FragmentSelection {
35583563
// If we're rebasing on a _different_ schema, then we *must* have fragments, since reusing
35593564
// `this.fragments` would be incorrect. If we're on the same schema though, we're happy to default
35603565
// to `this.fragments`.
3561-
assert(fragments || this.parentType.schema() === parentType.schema(), `Must provide fragments is rebasing on other schema`);
3566+
const rebaseOnSameSchema = this.parentType.schema() === parentType.schema();
3567+
assert(fragments || rebaseOnSameSchema, `Must provide fragments is rebasing on other schema`);
35623568
const newFragments = fragments ?? this.fragments;
35633569
const namedFragment = newFragments.get(this.namedFragment.name);
35643570
// If we're rebasing on another schema (think a subgraph), then named fragments will have been rebased on that, and some
@@ -3569,6 +3575,31 @@ class FragmentSpreadSelection extends FragmentSelection {
35693575
validate(!errorIfCannotRebase, () => `Cannot rebase ${this.toString(false)} if it isn't part of the provided fragments`);
35703576
return undefined;
35713577
}
3578+
3579+
// Lastly, if we rebase on a different schema, it's possible the fragment type does not intersect the
3580+
// parent type. For instance, the parent type could be some object type T while the fragment is an
3581+
// interface I, and T may implement I in the supergraph, but not in a particular subgraph (of course,
3582+
// if I don't exist at all in the subgraph, then we'll have exited above, but I may exist in the
3583+
// subgraph, just not be implemented by T for some reason). In that case, we can't reuse the fragment
3584+
// as its spread is essentially invalid in that position, so we have to replace it by the expansion
3585+
// of that fragment, which we rebase on the parentType (which in turn, will remove anythings within
3586+
// the fragment selection that needs removing, potentially everything).
3587+
if (!rebaseOnSameSchema && !runtimeTypesIntersects(parentType, namedFragment.typeCondition)) {
3588+
// Note that we've used the rebased `namedFragment` to check the type intersection because we needed to
3589+
// compare runtime types "for the schema we're rebasing into". But now that we're deciding to not reuse
3590+
// this rebased fragment, what we rebase is the selection set of the non-rebased fragment. And that's
3591+
// important because the very logic we're hitting here may need to happen inside the rebase do the
3592+
// fragment selection, but that logic would not be triggered if we used the rebased `namedFragment` since
3593+
// `rebaseOnSameSchema` would then be 'true'.
3594+
const expanded = this.namedFragment.selectionSet.rebaseOn({ parentType, fragments, errorIfCannotRebase });
3595+
// In theory, we could return the selection set directly, but making `Selection.rebaseOn` sometimes
3596+
// return a `SelectionSet` complicate things quite a bit. So instead, we encapsulate the selection set
3597+
// in an "empty" inline fragment. This make for non-really-optimal selection sets in the (relatively
3598+
// rare) case where this is triggered, but in practice this "inefficiency" is removed by future calls
3599+
// to `normalize`.
3600+
return expanded.isEmpty() ? undefined : new InlineFragmentSelection(new FragmentElement(parentType), expanded);
3601+
}
3602+
35723603
return new FragmentSpreadSelection(
35733604
parentType,
35743605
newFragments,

0 commit comments

Comments
 (0)