Skip to content

Commit 62e0d25

Browse files
author
Sylvain Lebresne
authored
Avoid leaving unused fragments (#2628)
Some interaction of the code that re-expand named fragments that are used only once and the normalization we code post expansion could leave some fragment unused, yet included in the list of fragment, which ultimately led to an invalid query. This commit make sure we don't include such unused fragments.
1 parent 1293034 commit 62e0d25

File tree

3 files changed

+113
-1
lines changed

3 files changed

+113
-1
lines changed

.changeset/fresh-ants-add.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@apollo/federation-internals": patch
3+
---
4+
5+
Fix issue where subgraph fetches may have unused fragments (and are thus invalid).
6+

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

+91
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,97 @@ describe('fragments optimization', () => {
14011401
`);
14021402
});
14031403
});
1404+
1405+
test('does not leave unused fragments', () => {
1406+
const schema = parseSchema(`
1407+
type Query {
1408+
t1: T1
1409+
}
1410+
1411+
union U1 = T1 | T2 | T3
1412+
union U2 = T2 | T3
1413+
1414+
type T1 {
1415+
x: Int
1416+
}
1417+
1418+
type T2 {
1419+
y: Int
1420+
}
1421+
1422+
type T3 {
1423+
z: Int
1424+
}
1425+
`);
1426+
const gqlSchema = schema.toGraphQLJSSchema();
1427+
1428+
const operation = parseOperation(schema, `
1429+
query {
1430+
t1 {
1431+
...Outer
1432+
}
1433+
}
1434+
1435+
fragment Outer on U1 {
1436+
... on T1 {
1437+
x
1438+
}
1439+
... on T2 {
1440+
... Inner
1441+
}
1442+
... on T3 {
1443+
... Inner
1444+
}
1445+
}
1446+
1447+
fragment Inner on U2 {
1448+
... on T2 {
1449+
y
1450+
}
1451+
}
1452+
`);
1453+
expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]);
1454+
1455+
const withoutFragments = operation.expandAllFragments();
1456+
expect(withoutFragments.toString()).toMatchString(`
1457+
{
1458+
t1 {
1459+
x
1460+
}
1461+
}
1462+
`);
1463+
1464+
// This is a bit of contrived example, but the reusing code will be able
1465+
// to figure out that the `Outer` fragment can be reused and will initially
1466+
// do so, but it's only use once, so it will expand it, which yields:
1467+
// {
1468+
// t1 {
1469+
// ... on T1 {
1470+
// x
1471+
// }
1472+
// ... on T2 {
1473+
// ... Inner
1474+
// }
1475+
// ... on T3 {
1476+
// ... Inner
1477+
// }
1478+
// }
1479+
// }
1480+
// and so `Inner` will not be expanded (it's used twice). Except that
1481+
// the `normalize` code is apply then and will _remove_ both instances
1482+
// of `.... Inner`. Which is ok, but we must make sure the fragment
1483+
// itself is removed since it is not used now, which this test ensures.
1484+
const optimized = withoutFragments.optimize(operation.fragments!, 2);
1485+
expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]);
1486+
1487+
expect(optimized.toString()).toMatchString(`
1488+
{
1489+
t1 {
1490+
x
1491+
}
1492+
}
1493+
`);
1494+
});
14041495
});
14051496

14061497
describe('validations', () => {

internals-js/src/operations.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,7 @@ export class Operation {
910910
return this;
911911
}
912912

913-
const finalFragments = computeFragmentsToKeep(optimizedSelection, fragments, minUsagesToOptimize);
913+
let finalFragments = computeFragmentsToKeep(optimizedSelection, fragments, minUsagesToOptimize);
914914

915915
// If there is fragment usages and we're not keeping all fragments, we need to expand fragments.
916916
if (finalFragments !== null && finalFragments?.size !== fragments.size) {
@@ -923,6 +923,21 @@ export class Operation {
923923
// Expanding fragments could create some "inefficiencies" that we wouldn't have if we hadn't re-optimized
924924
// the fragments to de-optimize it later, so we do a final "normalize" pass to remove those.
925925
optimizedSelection = optimizedSelection.normalize({ parentType: optimizedSelection.parentType });
926+
927+
// And if we've expanded some fragments but kept others, then it's not 100% impossible that some
928+
// fragment was used multiple times in some expanded fragment(s), but that post-expansion all of
929+
// it's usages are "dead" branches that are removed by the final `normalize`. In that case though,
930+
// we need to ensure we don't include the now-unused fragment in the final list of fragments.
931+
// TODO: remark that the same reasoning could leave a single instance of a fragment usage, so if
932+
// we really really want to never have less than `minUsagesToOptimize`, we could do some loop of
933+
// `expand then normalize` unless all fragments are provably used enough. We don't bother, because
934+
// leaving this is not a huge deal and it's not worth the complexity, but it could be that we can
935+
// refactor all this later to avoid this case without additional complexity.
936+
if (finalFragments) {
937+
const usages = new Map<string, number>();
938+
optimizedSelection.collectUsedFragmentNames(usages);
939+
finalFragments = finalFragments.filter((f) => (usages.get(f.name) ?? 0) > 0);
940+
}
926941
}
927942

928943
return this.withUpdatedSelectionSetAndFragments(optimizedSelection, finalFragments ?? undefined);

0 commit comments

Comments
 (0)