Skip to content

Commit 1bb7c51

Browse files
author
Sylvain Lebresne
authored
Fix normalization of fragment spreads (#2659)
The code that "normalize" selection sets to remove unecessary fragments is also used to remove impossibe (and thus invalid) branches when some reused fragments gets expanded away due to only be used once. However, while the code for inline fragments was correctly removing impossible branches, the similar code for named spreads was not, which in some rare cases could result in an invalid subgraph fetch.
1 parent 158d263 commit 1bb7c51

File tree

3 files changed

+124
-14
lines changed

3 files changed

+124
-14
lines changed

.changeset/rotten-ants-clean.md

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@apollo/federation-internals": patch
3+
---
4+
5+
Fix issue in the code to reuse fragments that, in some rare circumstances, could led to invalid queries where a named
6+
spread was use in an invalid position. If triggered, this resulted in an subgraph fetch whereby a named spread was
7+
used inside a sub-selection even though the spread condition did not intersect the parent type (the exact error message
8+
would depend on the client library used to handle subgraph fetches, but with GraphQL-js, the error message had the
9+
form "Fragment <F> cannot be spread here as objects of type <X> can never be of type <Y>").
10+

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

+92
Original file line numberDiff line numberDiff line change
@@ -1812,6 +1812,98 @@ describe('fragments optimization', () => {
18121812
}
18131813
`);
18141814
});
1815+
1816+
test('when a spread inside an expanded fragment should be "normalized away"', () => {
1817+
const schema = parseSchema(`
1818+
type Query {
1819+
t1: T1
1820+
i: I
1821+
}
1822+
1823+
interface I {
1824+
id: ID!
1825+
}
1826+
1827+
type T1 implements I {
1828+
id: ID!
1829+
a: Int
1830+
}
1831+
1832+
type T2 implements I {
1833+
id: ID!
1834+
b: Int
1835+
c: Int
1836+
}
1837+
`);
1838+
const gqlSchema = schema.toGraphQLJSSchema();
1839+
1840+
const operation = parseOperation(schema, `
1841+
{
1842+
t1 {
1843+
...GetAll
1844+
}
1845+
i {
1846+
...GetT2
1847+
}
1848+
}
1849+
1850+
fragment GetAll on I {
1851+
... on T1 {
1852+
a
1853+
}
1854+
...GetT2
1855+
... on T2 {
1856+
c
1857+
}
1858+
}
1859+
1860+
fragment GetT2 on T2 {
1861+
b
1862+
}
1863+
`);
1864+
expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]);
1865+
1866+
const withoutFragments = operation.expandAllFragments();
1867+
expect(withoutFragments.toString()).toMatchString(`
1868+
{
1869+
t1 {
1870+
a
1871+
}
1872+
i {
1873+
... on T2 {
1874+
b
1875+
}
1876+
}
1877+
}
1878+
`);
1879+
1880+
// As we re-optimize, we will initially generated the initial query. But
1881+
// as we ask to only optimize fragments used more than once, the `GetAll`
1882+
// fragment will be re-expanded (`GetT2` will not because the code will say
1883+
// that it is used both in the expanded `GetAll` but also inside `i`).
1884+
// But because `GetAll` is within `t1: T1`, that expansion should actually
1885+
// get rid of anything `T2`-related.
1886+
// This test exists because a previous version of the code was not correctly
1887+
// "getting rid" of the `...GetT2` spread, keeping in the query, which is
1888+
// invalid (we cannot have `...GetT2` inside `t1`).
1889+
const optimized = withoutFragments.optimize(operation.fragments!, 2);
1890+
expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]);
1891+
1892+
expect(optimized.toString()).toMatchString(`
1893+
fragment GetT2 on T2 {
1894+
b
1895+
}
1896+
1897+
{
1898+
t1 {
1899+
a
1900+
}
1901+
i {
1902+
...GetT2
1903+
}
1904+
}
1905+
`);
1906+
});
18151907
});
18161908

18171909
test('does not leave unused fragments', () => {

internals-js/src/operations.ts

+22-14
Original file line numberDiff line numberDiff line change
@@ -3137,6 +3137,26 @@ export abstract class FragmentSelection extends AbstractSelection<FragmentElemen
31373137
abstract equals(that: Selection): boolean;
31383138

31393139
abstract contains(that: Selection, options?: { ignoreMissingTypename?: boolean }): ContainsResult;
3140+
3141+
normalize({ parentType, recursive }: { parentType: CompositeType, recursive? : boolean }): FragmentSelection | SelectionSet | undefined {
3142+
const thisCondition = this.element.typeCondition;
3143+
3144+
// This method assumes by contract that `parentType` runtimes intersects `this.parentType`'s, but `parentType`
3145+
// runtimes may be a subset. So first check if the selection should not be discarded on that account (that
3146+
// is, we should not keep the selection if its condition runtimes don't intersect at all with those of
3147+
// `parentType` as that would ultimately make an invalid selection set).
3148+
if (thisCondition && parentType !== this.parentType) {
3149+
const conditionRuntimes = possibleRuntimeTypes(thisCondition);
3150+
const typeRuntimes = possibleRuntimeTypes(parentType);
3151+
if (!conditionRuntimes.some((t) => typeRuntimes.includes(t))) {
3152+
return undefined;
3153+
}
3154+
}
3155+
3156+
return this.normalizeKnowingItIntersects({ parentType, recursive });
3157+
}
3158+
3159+
protected abstract normalizeKnowingItIntersects({ parentType, recursive }: { parentType: CompositeType, recursive? : boolean }): FragmentSelection | SelectionSet | undefined;
31403160
}
31413161

31423162
class InlineFragmentSelection extends FragmentSelection {
@@ -3317,21 +3337,9 @@ class InlineFragmentSelection extends FragmentSelection {
33173337
: this.withUpdatedComponents(newElement, newSelection);
33183338
}
33193339

3320-
normalize({ parentType, recursive }: { parentType: CompositeType, recursive? : boolean }): FragmentSelection | SelectionSet | undefined {
3340+
protected normalizeKnowingItIntersects({ parentType, recursive }: { parentType: CompositeType, recursive? : boolean }): FragmentSelection | SelectionSet | undefined {
33213341
const thisCondition = this.element.typeCondition;
33223342

3323-
// This method assumes by contract that `parentType` runtimes intersects `this.parentType`'s, but `parentType`
3324-
// runtimes may be a subset. So first check if the selection should not be discarded on that account (that
3325-
// is, we should not keep the selection if its condition runtimes don't intersect at all with those of
3326-
// `parentType` as that would ultimately make an invalid selection set).
3327-
if (thisCondition && parentType !== this.parentType) {
3328-
const conditionRuntimes = possibleRuntimeTypes(thisCondition);
3329-
const typeRuntimes = possibleRuntimeTypes(parentType);
3330-
if (!conditionRuntimes.some((t) => typeRuntimes.includes(t))) {
3331-
return undefined;
3332-
}
3333-
}
3334-
33353343
// We know the condition is "valid", but it may not be useful. That said, if the condition has directives,
33363344
// we preserve the fragment no matter what.
33373345
if (this.element.appliedDirectives.length === 0) {
@@ -3472,7 +3480,7 @@ class FragmentSpreadSelection extends FragmentSelection {
34723480
assert(false, `Unsupported`);
34733481
}
34743482

3475-
normalize({ parentType }: { parentType: CompositeType }): FragmentSelection {
3483+
normalizeKnowingItIntersects({ parentType }: { parentType: CompositeType }): FragmentSelection {
34763484
// We must update the spread parent type if necessary since we're not going deeper,
34773485
// or we'll be fundamentally losing context.
34783486
assert(parentType.schema() === this.parentType.schema(), 'Should not try to normalize using a type from another schema');

0 commit comments

Comments
 (0)