Skip to content

Commit 877eb9e

Browse files
fix(composition): resolve @composeDirective definitions per-directive instead of per-spec (#3391)
## Summary Fixes a bug in `ComposeDirectiveManager` where `@composeDirective` would fail with a false `DIRECTIVE_COMPOSITION_ERROR` when two subgraphs linked the same custom spec but imported different subsets of its directives. Closes FED-849 --- ## Problem `getLatestDirectiveDefinition()` resolved a directive's definition through a two-hop chain: ``` directiveName → spec identity → "latest" subgraph for that spec → schema.directive(...) ``` The "latest" subgraph was determined solely by its spec version (highest minor). This meant that if the latest-version subgraph had not imported the specific directive being resolved, the lookup would silently fail and produce a false error — even though another subgraph had a perfectly valid definition for it. **Failing scenarios:** ```graphql # SG1 — imports @foo AND @bar, composes both @link(url: "https://myorg.dev/myspec/v1.0", import: ["@foo", "@bar"]) @composeDirective(name: "@foo") @composeDirective(name: "@bar") # SG2 — imports only @foo from the same spec @link(url: "https://myorg.dev/myspec/v1.0", import: ["@foo"]) @composeDirective(name: "@foo") ``` If the iterator elected SG2 as "latest", resolving `@bar` would fail: ``` DIRECTIVE_COMPOSITION_ERROR: Core feature "https://myorg.dev/myspec" in subgraph "SG2" does not have a directive definition for "@bar" ``` The bug was order-dependent: it succeeded only when the elected subgraph happened to be a superset of all other subgraphs' imports. Completely disjoint imports (SG1 has `@foo`, SG2 has `@bar`) always failed regardless of order. --- ## Fix Added a `directiveDefinitionMap: Map<string, DirectiveDefinition>` built during `validate()`. For each composed directive, it finds the highest-minor-version subgraph that **actually imported** that specific directive, and stores its definition directly. `getLatestDirectiveDefinition()` now does a single lookup on this map instead of the indirect two-hop chain. **Before:** ``` directiveName → spec identity → latestSubgraph (may not have the directive!) → definition ``` **After:** ``` directiveName → definition (sourced from the subgraph that actually imported it) ``` --- ## Changes | File | Description | |---|---| | `composition-js/src/composeDirectiveManager.ts` | New `directiveDefinitionMap`, populated in `validate()`, `getLatestDirectiveDefinition()` simplified to a direct lookup | | `composition-js/src/__tests__/compose.composeDirective.test.ts` | Two new regression tests covering the subset and fully-disjoint import scenarios; one existing test updated that was asserting the previous (buggy) error behavior | --- ## Testing ```bash # Regression tests for FED-849 node_modules/.bin/jest --projects composition-js --no-coverage --testNamePattern="FED-849" # Full composition suite node_modules/.bin/jest --projects composition-js --no-coverage ``` All 15 test suites pass (400 tests, 0 failures). --------- Co-authored-by: Dariusz Kuc <9501705+dariuszkuc@users.noreply.github.com>
1 parent 5b36fc6 commit 877eb9e

3 files changed

Lines changed: 188 additions & 35 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@apollo/composition": minor
3+
---
4+
5+
Update `@composeDirective` merge logic to allow subgraphs to import different directives from the same spec. Previously, all subgraphs had to import ALL (exactly the same) custom directives (even if they did not use them) as otherwise composition would fail with a false `DIRECTIVE_COMPOSITION_ERROR`.
6+
7+
**Affected scenario:** two or more subgraphs `@link` the same custom spec URL but import different directives from it:
8+
9+
```graphql
10+
# subgraph A — imports @foo AND @bar, composes both
11+
@link(url: "https://myorg.dev/myspec/v1.0", import: ["@foo", "@bar"])
12+
@composeDirective(name: "@foo") @composeDirective(name: "@bar")
13+
14+
# subgraph B — imports only @foo from the same spec
15+
@link(url: "https://myorg.dev/myspec/v1.0", import: ["@foo"])
16+
@composeDirective(name: "@foo")
17+
```
18+
19+
This would produce a false error:
20+
```
21+
DIRECTIVE_COMPOSITION_ERROR: Core feature "https://myorg.dev/myspec" in subgraph "B"
22+
does not have a directive definition for "@foo"
23+
```
24+
25+
The bug was order-dependent (failed when the subgraph elected as "latest" for the spec was not a superset of all other subgraphs' imports) and always failed for fully disjoint import sets (e.g. subgraph A imports only `@foo`, subgraph B imports only `@bar`).
26+
27+
**Root cause:** `ComposeDirectiveManager.getLatestDirectiveDefinition()` resolved definitions through a two-hop chain — `directiveName → spec identity → "latest" subgraph for that spec → schema.directive(...)`. The "latest" subgraph was determined by spec version alone, without checking whether it had actually imported the directive being resolved.
28+
29+
**Fix:** a `directiveDefinitionMap` (`directiveName → DirectiveDefinition`) is now built during `validate()`, sourcing each entry from the highest-minor-version subgraph that actually imported that specific directive. `getLatestDirectiveDefinition()` does a direct lookup on this map instead of the indirect chain.

composition-js/src/__tests__/compose.composeDirective.test.ts

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,115 @@ describe('composing custom core directives', () => {
156156
expectDirectiveOnElement(schema, 'User.a', 'foo', { name: 'a' });
157157
});
158158

159+
// FED-849: composition should succeed when subgraphs import different subsets
160+
// of directives from the same custom spec (e.g. SG1 imports [@foo, @bar], SG2 imports [@foo]).
161+
it('two subgraphs with same spec but disjoint directive imports (FED-849)', () => {
162+
// subgraphA imports both @foo and @bar from the spec and requests both to be composed
163+
const subgraphA = generateSubgraph({
164+
name: 'subgraphA',
165+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo", "@bar"])',
166+
composeText: '@composeDirective(name: "@foo") @composeDirective(name: "@bar")',
167+
directiveText: `
168+
directive @foo(name: String!) on FIELD_DEFINITION
169+
directive @bar(tag: String!) on FIELD_DEFINITION
170+
`,
171+
usage: '@foo(name: "a") @bar(tag: "t")',
172+
});
173+
174+
// subgraphB imports only @foo from the same spec and requests only @foo to be composed
175+
const subgraphB = generateSubgraph({
176+
name: 'subgraphB',
177+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])',
178+
composeText: '@composeDirective(name: "@foo")',
179+
directiveText: 'directive @foo(name: String!) on FIELD_DEFINITION',
180+
usage: '@foo(name: "b")',
181+
});
182+
183+
const result = composeServices([subgraphA, subgraphB]);
184+
const schema = expectNoErrors(result);
185+
186+
// both directives must be present in the supergraph
187+
expectDirectiveDefinition(schema, 'foo', [DirectiveLocation.FIELD_DEFINITION], ['name']);
188+
expectDirectiveDefinition(schema, 'bar', [DirectiveLocation.FIELD_DEFINITION], ['tag']);
189+
190+
// usages must be carried over correctly
191+
expectDirectiveOnElement(schema, 'User.subgraphA', 'foo', { name: 'a' });
192+
expectDirectiveOnElement(schema, 'User.subgraphA', 'bar', { tag: 't' });
193+
expectDirectiveOnElement(schema, 'User.subgraphB', 'foo', { name: 'b' });
194+
});
195+
196+
// FED-849 (disjoint variant): one subgraph imports only @foo, the other imports only @bar.
197+
// The resolve path must not go through the "latest feature" subgraph which may not have imported
198+
// the directive being resolved.
199+
it('two subgraphs with same spec importing completely disjoint directives (FED-849)', () => {
200+
// subgraphA imports only @foo
201+
const subgraphA = generateSubgraph({
202+
name: 'subgraphA',
203+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo"])',
204+
composeText: '@composeDirective(name: "@foo")',
205+
directiveText: 'directive @foo(name: String!) on FIELD_DEFINITION',
206+
usage: '@foo(name: "a")',
207+
});
208+
209+
// subgraphB imports only @bar from the same spec
210+
const subgraphB = generateSubgraph({
211+
name: 'subgraphB',
212+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@bar"])',
213+
composeText: '@composeDirective(name: "@bar")',
214+
directiveText: 'directive @bar(tag: String!) on FIELD_DEFINITION',
215+
usage: '@bar(tag: "t")',
216+
});
217+
218+
const result = composeServices([subgraphA, subgraphB]);
219+
const schema = expectNoErrors(result);
220+
221+
expectDirectiveDefinition(schema, 'foo', [DirectiveLocation.FIELD_DEFINITION], ['name']);
222+
expectDirectiveDefinition(schema, 'bar', [DirectiveLocation.FIELD_DEFINITION], ['tag']);
223+
224+
expectDirectiveOnElement(schema, 'User.subgraphA', 'foo', { name: 'a' });
225+
expectDirectiveOnElement(schema, 'User.subgraphB', 'bar', { tag: 't' });
226+
});
227+
228+
// FED-849 (cross-version variant): subgraphA is at foo/v1.0 and imports both @foo and @bar;
229+
// subgraphB is at foo/v1.1 (newer minor) but only imports @foo.
230+
// Without the fix, latestFeatureMap elects subgraphB (higher minor version) as the canonical
231+
// source for the entire spec, so resolving @bar fails because subgraphB never imported it.
232+
// With the fix, @bar is resolved directly from subgraphA — the only subgraph that imported it.
233+
it('older subgraph imports superset of directives, newer subgraph imports subset (FED-849)', () => {
234+
// subgraphA is on the older spec version but imports both directives
235+
const subgraphA = generateSubgraph({
236+
name: 'subgraphA',
237+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.0", import: ["@foo", "@bar"])',
238+
composeText: '@composeDirective(name: "@foo") @composeDirective(name: "@bar")',
239+
directiveText: `
240+
directive @foo(name: String!) on FIELD_DEFINITION
241+
directive @bar(tag: String!) on FIELD_DEFINITION
242+
`,
243+
usage: '@foo(name: "a") @bar(tag: "t")',
244+
});
245+
246+
// subgraphB is on the newer spec version but only imports @foo — does NOT import @bar
247+
const subgraphB = generateSubgraph({
248+
name: 'subgraphB',
249+
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.1", import: ["@foo"])',
250+
composeText: '@composeDirective(name: "@foo")',
251+
directiveText: 'directive @foo(name: String!) on FIELD_DEFINITION',
252+
usage: '@foo(name: "b")',
253+
});
254+
255+
// @bar must be resolved from subgraphA, not from subgraphB (which has the newer spec version
256+
// but never imported @bar). Composition should succeed.
257+
const result = composeServices([subgraphA, subgraphB]);
258+
const schema = expectNoErrors(result);
259+
260+
expectDirectiveDefinition(schema, 'foo', [DirectiveLocation.FIELD_DEFINITION], ['name']);
261+
expectDirectiveDefinition(schema, 'bar', [DirectiveLocation.FIELD_DEFINITION], ['tag']);
262+
263+
expectDirectiveOnElement(schema, 'User.subgraphA', 'foo', { name: 'a' });
264+
expectDirectiveOnElement(schema, 'User.subgraphA', 'bar', { tag: 't' });
265+
expectDirectiveOnElement(schema, 'User.subgraphB', 'foo', { name: 'b' });
266+
});
267+
159268
it('different major versions of core feature results in hint if not composed', () => {
160269
const subgraphA = generateSubgraph({
161270
name: 'subgraphA',
@@ -549,7 +658,11 @@ describe('composing custom core directives', () => {
549658
expectDirectiveOnElement(schema, 'User.subgraphB', 'bar', { name: 'b' });
550659
});
551660

552-
it('exported directive not imported everywhere. no definition', () => {
661+
// FED-849 (version mismatch variant): subgraphA is at foo/v1.1 and only imports @foo;
662+
// subgraphB is at foo/v1.0 and only imports @bar. The "latest" subgraph (A, v1.1) does not
663+
// have @bar in its schema, but the fix resolves @bar's definition directly from subgraphB,
664+
// so composition must succeed instead of raising a false "no definition" error.
665+
it('exported directive not imported everywhere. no definition in latest-version subgraph should still compose (FED-849)', () => {
553666
const subgraphA = generateSubgraph({
554667
name: 'subgraphA',
555668
linkText: '@link(url: "https://specs.apollo.dev/foo/v1.1", import: ["@foo"])',
@@ -570,14 +683,14 @@ describe('composing custom core directives', () => {
570683
usage: '@bar(name: "b")',
571684
});
572685

686+
// @bar's definition is resolved from subgraphB (the only subgraph that imported it),
687+
// so composition should succeed without errors.
573688
const result = composeServices([subgraphA, subgraphB]);
574-
expect(errors(result)).toStrictEqual([
575-
[
576-
'DIRECTIVE_COMPOSITION_ERROR',
577-
'Core feature "https://specs.apollo.dev/foo" in subgraph "subgraphA" does not have a directive definition for "@bar"',
578-
]
579-
]);
580-
expect(hints(result)).toStrictEqual([]);
689+
const schema = expectNoErrors(result);
690+
expectDirectiveDefinition(schema, 'foo', [DirectiveLocation.FIELD_DEFINITION], ['name']);
691+
expectDirectiveDefinition(schema, 'bar', [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT], ['name', 'address']);
692+
expectDirectiveOnElement(schema, 'User.subgraphA', 'foo', { name: 'a' });
693+
expectDirectiveOnElement(schema, 'User.subgraphB', 'bar', { name: 'b' });
581694
});
582695

583696
it.todo('composing same major version, but incompatible directives results in error');

composition-js/src/composeDirectiveManager.ts

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ export class ComposeDirectiveManager {
8080
// map of directive names to identity,origName
8181
directiveIdentityMap: Map<string, [string,string]>;
8282

83+
// map of directive names to a resolved DirectiveDefinition, sourced from the subgraph
84+
// that actually imported each directive (not necessarily the "latest" subgraph for that spec).
85+
// This avoids the FED-849 bug where the latest-spec subgraph may not have imported the directive.
86+
directiveDefinitionMap: Map<string, DirectiveDefinition>;
87+
8388
mismatchReporter: MismatchReporter;
8489

8590
constructor(
@@ -90,6 +95,7 @@ export class ComposeDirectiveManager {
9095
this.mergeDirectiveMap = new Map();
9196
this.latestFeatureMap = new Map();
9297
this.directiveIdentityMap = new Map();
98+
this.directiveDefinitionMap = new Map();
9399
this.mismatchReporter = new MismatchReporter(subgraphs.names(), pushError, pushHint);
94100
}
95101

@@ -436,6 +442,33 @@ export class ComposeDirectiveManager {
436442
this.mergeDirectiveMap.set(subgraph, directivesForSubgraph);
437443
}
438444

445+
// Build a direct mapping from directive name to its definition, sourced from the subgraph
446+
// with the highest minor version that actually imported it. This is the fix for FED-849:
447+
// instead of resolving through latestFeatureMap (spec → one subgraph that may not have
448+
// imported the directive), we track exactly which subgraph imported each directive.
449+
for (const [directiveName, items] of itemsByDirectiveName.entries()) {
450+
if (wontMergeDirectiveNames.has(directiveName)) {
451+
continue;
452+
}
453+
// Sort by minor version descending so we pick the highest compatible version first.
454+
const candidates = items
455+
.filter(item => !wontMergeFeatures.has(item.feature.url.identity))
456+
.sort((a, b) => b.feature.url.version.minor - a.feature.url.version.minor);
457+
458+
for (const item of candidates) {
459+
const sg = this.subgraphs.get(item.sgName);
460+
assert(sg, `subgraph "${item.sgName}" does not exist`);
461+
const nameInSchema = sg.schema.coreFeatures
462+
?.getByIdentity(item.feature.url.identity)
463+
?.directiveNameInSchema(item.directiveName);
464+
const def = nameInSchema ? sg.schema.directive(nameInSchema) : undefined;
465+
if (def) {
466+
this.directiveDefinitionMap.set(directiveName, def);
467+
break;
468+
}
469+
}
470+
}
471+
439472
return {
440473
errors,
441474
hints,
@@ -455,33 +488,11 @@ export class ComposeDirectiveManager {
455488
}
456489

457490
getLatestDirectiveDefinition(directiveName: string): DirectiveDefinition | undefined {
458-
const val = this.directiveIdentityMap.get(directiveName);
459-
if (val) {
460-
const [identity, origName] = val;
461-
const entry = this.latestFeatureMap.get(identity);
462-
assert(entry, 'core feature identity must exist in map');
463-
const [feature, subgraphName] = entry;
464-
const subgraph = this.subgraphs.get(subgraphName);
465-
assert(subgraph, `subgraph "${subgraphName}" does not exist`);
466-
467-
// we need to convert from the name that is used in the schemas that export the directive
468-
// to the name used in the schema that is the latest version, which may or may not export
469-
// See test "exported directive not imported everywhere. imported with different name"
470-
const nameInSchema = subgraph.schema.coreFeatures?.getByIdentity(identity)?.directiveNameInSchema(origName);
471-
if (nameInSchema) {
472-
const directive = subgraph.schema.directive(nameInSchema);
473-
if (!directive) {
474-
this.pushError(ERRORS.DIRECTIVE_COMPOSITION_ERROR.err(
475-
`Core feature "${identity}" in subgraph "${subgraphName}" does not have a directive definition for "@${directiveName}"`,
476-
{
477-
nodes: feature.directive.sourceAST,
478-
},
479-
));
480-
}
481-
return directive;
482-
}
483-
}
484-
return undefined;
491+
// Resolve directly from the per-directive map that was populated in validate().
492+
// Each entry is sourced from the highest-minor-version subgraph that actually imported
493+
// the directive, avoiding the FED-849 bug where the "latest spec" subgraph may not
494+
// have imported every directive from that spec.
495+
return this.directiveDefinitionMap.get(directiveName);
485496
}
486497

487498
private directivesForFeature(identity: string): [string,string][] {

0 commit comments

Comments
 (0)