Skip to content

Commit e6c05b6

Browse files
Fix value comparison, argument merging, and link feature addition/extraction in composition (#3134)
While reviewing recent code changes that released in `2.9.0`, I noticed a few bugs in the code, so I've pushed some fixes for them in this PR (along with some fixes for more ancient bugs). I'll comment on the code with more details, but to summarize the fixes: - Value comparison/default value application has been updated to handle `null`s/`undefined`s better. - Composition argument strategy code has been refactored to handle nullability more cleanly (and fix some bugs around nullability). - Note I've commented out the unused `SUM` strategy, as using it currently wouldn't yield expected results since directive applications are deduped before being merged. - The logic that applies `@link` applications to the supergraph schema for supergraph specs needed by subgraph directives has been updated to handle specs that have multiple directives that need composing (the behavior before was adding spurious directive definitions to the supergraph schema). - Some ancient logic around computing directive/type names for `@core`/`@link` was off.
1 parent fd2db6f commit e6c05b6

File tree

9 files changed

+287
-160
lines changed

9 files changed

+287
-160
lines changed

.changeset/proud-days-press.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/federation-internals": patch
3+
"@apollo/gateway": patch
4+
"@apollo/composition": patch
5+
---
6+
7+
Fix bugs in composition when merging nulls in directive applications and when handling renames.

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

+14-12
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,19 @@ describe('composition of directive with non-trivial argument strategies', () =>
8989
t: 2, k: 1, b: 4,
9090
},
9191
}, {
92-
name: 'sum',
93-
type: (schema: Schema) => new NonNullType(schema.intType()),
94-
compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.SUM,
95-
argValues: {
96-
s1: { t: 3, k: 1 },
97-
s2: { t: 2, k: 5, b: 4 },
98-
},
99-
resultValues: {
100-
t: 5, k: 6, b: 4,
101-
},
102-
}, {
92+
// NOTE: See the note for the SUM strategy in argumentCompositionStrategies.ts
93+
// for more information on why this is commented out.
94+
// name: 'sum',
95+
// type: (schema: Schema) => new NonNullType(schema.intType()),
96+
// compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.SUM,
97+
// argValues: {
98+
// s1: { t: 3, k: 1 },
99+
// s2: { t: 2, k: 5, b: 4 },
100+
// },
101+
// resultValues: {
102+
// t: 5, k: 6, b: 4,
103+
// },
104+
// }, {
103105
name: 'intersection',
104106
type: (schema: Schema) => new NonNullType(new ListType(new NonNullType(schema.stringType()))),
105107
compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.INTERSECTION,
@@ -219,7 +221,7 @@ describe('composition of directive with non-trivial argument strategies', () =>
219221
const s = result.schema;
220222

221223
expect(directiveStrings(s.schemaDefinition, name)).toStrictEqual([
222-
`@link(url: "https://specs.apollo.dev/${name}/v0.1", import: ["@${name}"])`
224+
`@link(url: "https://specs.apollo.dev/${name}/v0.1")`
223225
]);
224226

225227
const t = s.type('T') as ObjectType;

composition-js/src/merging/merge.ts

+131-57
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import {
3131
CompositeType,
3232
Subgraphs,
3333
JOIN_VERSIONS,
34-
INACCESSIBLE_VERSIONS,
3534
NamedSchemaElement,
3635
errorCauses,
3736
isObjectType,
@@ -69,7 +68,6 @@ import {
6968
CoreSpecDefinition,
7069
FeatureVersion,
7170
FEDERATION_VERSIONS,
72-
InaccessibleSpecDefinition,
7371
LinkDirectiveArgs,
7472
sourceIdentity,
7573
FeatureUrl,
@@ -81,6 +79,10 @@ import {
8179
isNullableType,
8280
isFieldDefinition,
8381
Post20FederationDirectiveDefinition,
82+
DirectiveCompositionSpecification,
83+
FeatureDefinition,
84+
CoreImport,
85+
inaccessibleIdentity,
8486
} from "@apollo/federation-internals";
8587
import { ASTNode, GraphQLError, DirectiveLocation } from "graphql";
8688
import {
@@ -345,6 +347,12 @@ interface OverrideArgs {
345347
label?: string;
346348
}
347349

350+
interface MergedDirectiveInfo {
351+
definition: DirectiveDefinition;
352+
argumentsMerger?: ArgumentMerger;
353+
staticArgumentTransform?: StaticArgumentsTransform;
354+
}
355+
348356
class Merger {
349357
readonly names: readonly string[];
350358
readonly subgraphsSchema: readonly Schema[];
@@ -353,7 +361,8 @@ class Merger {
353361
readonly merged: Schema = new Schema();
354362
readonly subgraphNamesToJoinSpecName: Map<string, string>;
355363
readonly mergedFederationDirectiveNames = new Set<string>();
356-
readonly mergedFederationDirectiveInSupergraph = new Map<string, { definition: DirectiveDefinition, argumentsMerger?: ArgumentMerger, staticArgumentTransform?: StaticArgumentsTransform }>();
364+
readonly mergedFederationDirectiveInSupergraphByDirectiveName =
365+
new Map<string, MergedDirectiveInfo>();
357366
readonly enumUsages = new Map<string, EnumTypeUsage>();
358367
private composeDirectiveManager: ComposeDirectiveManager;
359368
private mismatchReporter: MismatchReporter;
@@ -364,7 +373,7 @@ class Merger {
364373
}[];
365374
private joinSpec: JoinSpecDefinition;
366375
private linkSpec: CoreSpecDefinition;
367-
private inaccessibleSpec: InaccessibleSpecDefinition;
376+
private inaccessibleDirectiveInSupergraph?: DirectiveDefinition;
368377
private latestFedVersionUsed: FeatureVersion;
369378
private joinDirectiveIdentityURLs = new Set<string>();
370379
private schemaToImportNameToFeatureUrl = new Map<Schema, Map<string, FeatureUrl>>();
@@ -375,7 +384,6 @@ class Merger {
375384
this.latestFedVersionUsed = this.getLatestFederationVersionUsed();
376385
this.joinSpec = JOIN_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed);
377386
this.linkSpec = LINK_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed);
378-
this.inaccessibleSpec = INACCESSIBLE_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed);
379387
this.fieldsWithFromContext = this.getFieldsWithFromContextDirective();
380388
this.fieldsWithOverride = this.getFieldsWithOverrideDirective();
381389

@@ -470,59 +478,127 @@ class Merger {
470478
assert(errors.length === 0, "We shouldn't have errors adding the join spec to the (still empty) supergraph schema");
471479

472480
const directivesMergeInfo = collectCoreDirectivesToCompose(this.subgraphs);
473-
for (const mergeInfo of directivesMergeInfo) {
474-
this.validateAndMaybeAddSpec(mergeInfo);
475-
}
476-
481+
this.validateAndMaybeAddSpecs(directivesMergeInfo);
477482
return this.joinSpec.populateGraphEnum(this.merged, this.subgraphs);
478483
}
479484

480-
private validateAndMaybeAddSpec({url, name, definitionsPerSubgraph, compositionSpec}: CoreDirectiveInSubgraphs) {
481-
// Not composition specification means that it shouldn't be composed.
482-
if (!compositionSpec) {
483-
return;
484-
}
485-
486-
let nameInSupergraph: string | undefined;
487-
for (const subgraph of this.subgraphs) {
488-
const directive = definitionsPerSubgraph.get(subgraph.name);
489-
if (!directive) {
490-
continue;
485+
private validateAndMaybeAddSpecs(directivesMergeInfo: CoreDirectiveInSubgraphs[]) {
486+
const supergraphInfoByIdentity = new Map<
487+
string,
488+
{
489+
specInSupergraph: FeatureDefinition;
490+
directives: {
491+
nameInFeature: string;
492+
nameInSupergraph: string;
493+
compositionSpec: DirectiveCompositionSpecification;
494+
}[];
491495
}
496+
>;
492497

493-
if (!nameInSupergraph) {
494-
nameInSupergraph = directive.name;
495-
} else if (nameInSupergraph !== directive.name) {
496-
this.mismatchReporter.reportMismatchError(
497-
ERRORS.LINK_IMPORT_NAME_MISMATCH,
498-
`The "@${name}" directive (from ${url}) is imported with mismatched name between subgraphs: it is imported as `,
499-
directive,
500-
sourcesFromArray(this.subgraphs.values().map((s) => definitionsPerSubgraph.get(s.name))),
501-
(def) => `"@${def.name}"`,
502-
);
498+
for (const {url, name, definitionsPerSubgraph, compositionSpec} of directivesMergeInfo) {
499+
// No composition specification means that it shouldn't be composed.
500+
if (!compositionSpec) {
503501
return;
504502
}
503+
504+
let nameInSupergraph: string | undefined;
505+
for (const subgraph of this.subgraphs) {
506+
const directive = definitionsPerSubgraph.get(subgraph.name);
507+
if (!directive) {
508+
continue;
509+
}
510+
511+
if (!nameInSupergraph) {
512+
nameInSupergraph = directive.name;
513+
} else if (nameInSupergraph !== directive.name) {
514+
this.mismatchReporter.reportMismatchError(
515+
ERRORS.LINK_IMPORT_NAME_MISMATCH,
516+
`The "@${name}" directive (from ${url}) is imported with mismatched name between subgraphs: it is imported as `,
517+
directive,
518+
sourcesFromArray(this.subgraphs.values().map((s) => definitionsPerSubgraph.get(s.name))),
519+
(def) => `"@${def.name}"`,
520+
);
521+
return;
522+
}
523+
}
524+
525+
// If we get here with `nameInSupergraph` unset, it means there is no usage for the directive at all and we
526+
// don't bother adding the spec to the supergraph.
527+
if (nameInSupergraph) {
528+
const specInSupergraph = compositionSpec.supergraphSpecification(this.latestFedVersionUsed);
529+
let supergraphInfo = supergraphInfoByIdentity.get(specInSupergraph.url.identity);
530+
if (supergraphInfo) {
531+
assert(
532+
specInSupergraph.url.equals(supergraphInfo.specInSupergraph.url),
533+
`Spec ${specInSupergraph.url} directives disagree on version for supergraph`,
534+
);
535+
} else {
536+
supergraphInfo = {
537+
specInSupergraph,
538+
directives: [],
539+
};
540+
supergraphInfoByIdentity.set(specInSupergraph.url.identity, supergraphInfo);
541+
}
542+
supergraphInfo.directives.push({
543+
nameInFeature: name,
544+
nameInSupergraph,
545+
compositionSpec,
546+
});
547+
}
505548
}
506549

507-
// If we get here with `nameInSupergraph` unset, it means there is no usage for the directive at all and we
508-
// don't bother adding the spec to the supergraph.
509-
if (nameInSupergraph) {
510-
const specInSupergraph = compositionSpec.supergraphSpecification(this.latestFedVersionUsed);
511-
const errors = this.linkSpec.applyFeatureAsLink(this.merged, specInSupergraph, specInSupergraph.defaultCorePurpose, [{ name, as: name === nameInSupergraph ? undefined : nameInSupergraph }], );
512-
assert(errors.length === 0, "We shouldn't have errors adding the join spec to the (still empty) supergraph schema");
513-
const feature = this.merged?.coreFeatures?.getByIdentity(specInSupergraph.url.identity);
550+
for (const { specInSupergraph, directives } of supergraphInfoByIdentity.values()) {
551+
const imports: CoreImport[] = [];
552+
for (const { nameInFeature, nameInSupergraph } of directives) {
553+
const defaultNameInSupergraph = CoreFeature.directiveNameInSchemaForCoreArguments(
554+
specInSupergraph.url,
555+
specInSupergraph.url.name,
556+
[],
557+
nameInFeature,
558+
);
559+
if (nameInSupergraph !== defaultNameInSupergraph) {
560+
imports.push(nameInFeature === nameInSupergraph
561+
? { name: `@${nameInFeature}` }
562+
: { name: `@${nameInFeature}`, as: `@${nameInSupergraph}` }
563+
);
564+
}
565+
}
566+
const errors = this.linkSpec.applyFeatureToSchema(
567+
this.merged,
568+
specInSupergraph,
569+
undefined,
570+
specInSupergraph.defaultCorePurpose,
571+
imports,
572+
);
573+
assert(
574+
errors.length === 0,
575+
"We shouldn't have errors adding the join spec to the (still empty) supergraph schema"
576+
);
577+
const feature = this.merged.coreFeatures?.getByIdentity(specInSupergraph.url.identity);
514578
assert(feature, 'Should have found the feature we just added');
515-
const argumentsMerger = compositionSpec.argumentsMerger?.call(null, this.merged, feature);
516-
if (argumentsMerger instanceof GraphQLError) {
517-
// That would mean we made a mistake in the declaration of a hard-coded directive, so we just throw right away so this can be caught and corrected.
518-
throw argumentsMerger;
519-
}
520-
this.mergedFederationDirectiveNames.add(nameInSupergraph);
521-
this.mergedFederationDirectiveInSupergraph.set(name, {
522-
definition: this.merged.directive(nameInSupergraph)!,
523-
argumentsMerger,
524-
staticArgumentTransform: compositionSpec.staticArgumentTransform,
525-
});
579+
for (const { nameInFeature, nameInSupergraph, compositionSpec } of directives) {
580+
const argumentsMerger = compositionSpec.argumentsMerger?.call(null, this.merged, feature);
581+
if (argumentsMerger instanceof GraphQLError) {
582+
// That would mean we made a mistake in the declaration of a hard-coded directive,
583+
// so we just throw right away so this can be caught and corrected.
584+
throw argumentsMerger;
585+
}
586+
this.mergedFederationDirectiveNames.add(nameInSupergraph);
587+
this.mergedFederationDirectiveInSupergraphByDirectiveName.set(nameInSupergraph, {
588+
definition: this.merged.directive(nameInSupergraph)!,
589+
argumentsMerger,
590+
staticArgumentTransform: compositionSpec.staticArgumentTransform,
591+
});
592+
// If we encounter the @inaccessible directive, we need to record its
593+
// definition so certain merge validations that care about @inaccessible
594+
// can act accordingly.
595+
if (
596+
specInSupergraph.identity === inaccessibleIdentity
597+
&& nameInFeature === specInSupergraph.url.name
598+
) {
599+
this.inaccessibleDirectiveInSupergraph = this.merged.directive(nameInSupergraph)!;
600+
}
601+
}
526602
}
527603
}
528604

@@ -2464,8 +2540,8 @@ class Merger {
24642540
this.recordAppliedDirectivesToMerge(valueSources, value);
24652541
this.addJoinEnumValue(valueSources, value);
24662542

2467-
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(this.inaccessibleSpec.inaccessibleDirectiveSpec.name);
2468-
const isInaccessible = inaccessibleInSupergraph && value.hasAppliedDirective(inaccessibleInSupergraph.definition);
2543+
const isInaccessible = this.inaccessibleDirectiveInSupergraph
2544+
&& value.hasAppliedDirective(this.inaccessibleDirectiveInSupergraph);
24692545
// The merging strategy depends on the enum type usage:
24702546
// - if it is _only_ used in position of Input type, we merge it with an "intersection" strategy (like other input types/things).
24712547
// - if it is _only_ used in position of Output type, we merge it with an "union" strategy (like other output types/things).
@@ -2562,8 +2638,6 @@ class Merger {
25622638
}
25632639

25642640
private mergeInput(inputSources: Sources<InputObjectType>, dest: InputObjectType) {
2565-
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(this.inaccessibleSpec.inaccessibleDirectiveSpec.name);
2566-
25672641
// Like for other inputs, we add all the fields found in any subgraphs initially as a simple mean to have a complete list of
25682642
// field to iterate over, but we will remove those that are not in all subgraphs.
25692643
const added = this.addFieldsShallow(inputSources, dest);
@@ -2572,7 +2646,8 @@ class Merger {
25722646
// compatibility between definitions and 2) we actually want to see if the result is marked inaccessible or not and it makes
25732647
// that easier.
25742648
this.mergeInputField(subgraphFields, destField);
2575-
const isInaccessible = inaccessibleInSupergraph && destField.hasAppliedDirective(inaccessibleInSupergraph.definition);
2649+
const isInaccessible = this.inaccessibleDirectiveInSupergraph
2650+
&& destField.hasAppliedDirective(this.inaccessibleDirectiveInSupergraph);
25762651
// Note that if the field is manually marked @inaccessible, we can always accept it to be inconsistent between subgraphs since
25772652
// it won't be exposed in the API, and we don't hint about it because we're just doing what the user is explicitely asking.
25782653
if (!isInaccessible && someSources(subgraphFields, field => !field)) {
@@ -2840,8 +2915,7 @@ class Merger {
28402915
// is @inaccessible, which is necessary to exist in the supergraph for EnumValues to properly
28412916
// determine whether the fact that a value is both input / output will matter
28422917
private recordAppliedDirectivesToMerge(sources: Sources<SchemaElement<any, any>>, dest: SchemaElement<any, any>) {
2843-
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(this.inaccessibleSpec.inaccessibleDirectiveSpec.name);
2844-
const inaccessibleName = inaccessibleInSupergraph?.definition.name;
2918+
const inaccessibleName = this.inaccessibleDirectiveInSupergraph?.name;
28452919
const names = this.gatherAppliedDirectiveNames(sources);
28462920

28472921
if (inaccessibleName && names.has(inaccessibleName)) {
@@ -2905,7 +2979,7 @@ class Merger {
29052979
return;
29062980
}
29072981

2908-
const directiveInSupergraph = this.mergedFederationDirectiveInSupergraph.get(name);
2982+
const directiveInSupergraph = this.mergedFederationDirectiveInSupergraphByDirectiveName.get(name);
29092983

29102984
if (dest.schema().directive(name)?.repeatable) {
29112985
// For repeatable directives, we simply include each application found but with exact duplicates removed
@@ -2945,7 +3019,7 @@ class Merger {
29453019
if (differentApplications.length === 1) {
29463020
dest.applyDirective(name, differentApplications[0].arguments(false));
29473021
} else {
2948-
const info = this.mergedFederationDirectiveInSupergraph.get(name);
3022+
const info = this.mergedFederationDirectiveInSupergraphByDirectiveName.get(name);
29493023
if (info && info.argumentsMerger) {
29503024
const mergedArguments = Object.create(null);
29513025
const applicationsArguments = differentApplications.map((a) => a.arguments(true));

gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('lifecycle hooks', () => {
149149
// the supergraph (even just formatting differences), this ID will change
150150
// and this test will have to updated.
151151
expect(secondCall[0]!.compositionId).toMatchInlineSnapshot(
152-
`"4aa2278e35df345ff5959a30546d2e9ef9e997204b4ffee4a42344b578b36068"`,
152+
`"6dc1bde2b9818fabec62208c5d8825abaa1bae89635fa6f3a5ffea7b78fc6d82"`,
153153
);
154154
// second call should have previous info in the second arg
155155
expect(secondCall[1]!.compositionId).toEqual(expectedFirstId);

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

+1
Original file line numberDiff line numberDiff line change
@@ -414,5 +414,6 @@ describe('objectEquals tests', () => {
414414
expect(valueEquals({ foo: 'foo', bar: undefined }, { foo: 'foo' })).toBe(
415415
false,
416416
);
417+
expect(valueEquals({}, null)).toBe(false);
417418
});
418419
});

0 commit comments

Comments
 (0)