Skip to content

Commit 6b18af5

Browse files
Use required version of federation coreSpec rather than latest (#2528)
For core specs used by federation, rather than using the latest version of a core spec, we will use the latest version that is implied by the version of federation requested to be composed. This will be true going forward only, will not downgrade any existing supergraph schemas. --------- Co-authored-by: Trevor Scheer <trevor.scheer@gmail.com>
1 parent 3798809 commit 6b18af5

10 files changed

Lines changed: 136 additions & 45 deletions

File tree

.changeset/gentle-lies-give.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/composition": minor
3+
"@apollo/federation-internals": minor
4+
---
5+
6+
For CoreSpecDefintions that opt in, we've added the ability to tie the core spec version to a particular federation version. That means that if there's a new version of, say, the join spec, you won't necessarily get the new version in the supergraph schema if no subgraph requires it.
7+

composition-js/src/merging/merge.ts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ import {
6666
addSubgraphToError,
6767
printHumanReadableList,
6868
ArgumentMerger,
69+
JoinSpecDefinition,
70+
CoreSpecDefinition,
71+
FeatureVersion,
72+
FEDERATION_VERSIONS,
73+
InaccessibleSpecDefinition,
6974
} from "@apollo/federation-internals";
7075
import { ASTNode, GraphQLError, DirectiveLocation } from "graphql";
7176
import {
@@ -79,13 +84,8 @@ import { inspect } from "util";
7984
import { collectCoreDirectivesToCompose, CoreDirectiveInSubgraphs } from "./coreDirectiveCollector";
8085
import { CompositionOptions } from "../compose";
8186

82-
83-
const linkSpec = LINK_VERSIONS.latest();
8487
type FieldOrUndefinedArray = (FieldDefinition<any> | undefined)[];
8588

86-
const joinSpec = JOIN_VERSIONS.latest();
87-
const inaccessibleSpec = INACCESSIBLE_VERSIONS.latest();
88-
8989
export type MergeResult = MergeSuccess | MergeFailure;
9090

9191
type FieldMergeContextProperties = {
@@ -275,8 +275,17 @@ class Merger {
275275
sources: (SchemaElement<any, any> | undefined)[],
276276
dest: SchemaElement<any, any>,
277277
}[];
278+
private joinSpec: JoinSpecDefinition;
279+
private linkSpec: CoreSpecDefinition;
280+
private inaccessibleSpec: InaccessibleSpecDefinition;
281+
private latestFedVersionUsed: FeatureVersion;
278282

279283
constructor(readonly subgraphs: Subgraphs, readonly options: CompositionOptions) {
284+
this.latestFedVersionUsed = this.getLatestFederationVersionUsed();
285+
this.joinSpec = JOIN_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed);
286+
this.linkSpec = LINK_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed);
287+
this.inaccessibleSpec = INACCESSIBLE_VERSIONS.getMinimumRequiredVersion(this.latestFedVersionUsed);
288+
280289
this.names = subgraphs.names();
281290
this.composeDirectiveManager = new ComposeDirectiveManager(
282291
this.subgraphs,
@@ -293,20 +302,34 @@ class Merger {
293302
this.appliedDirectivesToMerge = [];
294303
}
295304

305+
private getLatestFederationVersionUsed(): FeatureVersion {
306+
const latestVersion = this.subgraphs.values().reduce((latest: FeatureVersion | undefined, subgraph) => {
307+
const version = subgraph.metadata()?.federationFeature()?.url?.version;
308+
if (!latest) {
309+
return version;
310+
}
311+
if (!version) {
312+
return latest;
313+
}
314+
return latest >= version ? latest : version;
315+
}, undefined);
316+
return latestVersion ?? FEDERATION_VERSIONS.latest().version;
317+
}
318+
296319
private prepareSupergraph(): Map<string, string> {
297320
// TODO: we will soon need to look for name conflicts for @core and @join with potentially user-defined directives and
298321
// pass a `as` to the methods below if necessary. However, as we currently don't propagate any subgraph directives to
299322
// the supergraph outside of a few well-known ones, we don't bother yet.
300-
linkSpec.addToSchema(this.merged);
301-
const errors = linkSpec.applyFeatureToSchema(this.merged, joinSpec, undefined, joinSpec.defaultCorePurpose);
323+
this.linkSpec.addToSchema(this.merged);
324+
const errors = this.linkSpec.applyFeatureToSchema(this.merged, this.joinSpec, undefined, this.joinSpec.defaultCorePurpose);
302325
assert(errors.length === 0, "We shouldn't have errors adding the join spec to the (still empty) supergraph schema");
303326

304327
const directivesMergeInfo = collectCoreDirectivesToCompose(this.subgraphs);
305328
for (const mergeInfo of directivesMergeInfo) {
306329
this.validateAndMaybeAddSpec(mergeInfo);
307330
}
308331

309-
return joinSpec.populateGraphEnum(this.merged, this.subgraphs);
332+
return this.joinSpec.populateGraphEnum(this.merged, this.subgraphs);
310333
}
311334

312335
private validateAndMaybeAddSpec({feature, name, definitionsPerSubgraph, compositionSpec}: CoreDirectiveInSubgraphs) {
@@ -339,8 +362,8 @@ class Merger {
339362
// If we get here with `nameInSupergraph` unset, it means there is no usage for the directive at all and we
340363
// don't bother adding the spec to the supergraph.
341364
if (nameInSupergraph) {
342-
const specInSupergraph = compositionSpec.supergraphSpecification();
343-
const errors = linkSpec.applyFeatureToSchema(this.merged, specInSupergraph, nameInSupergraph === specInSupergraph.url.name ? undefined : nameInSupergraph, specInSupergraph.defaultCorePurpose);
365+
const specInSupergraph = compositionSpec.supergraphSpecification(this.latestFedVersionUsed);
366+
const errors = this.linkSpec.applyFeatureToSchema(this.merged, specInSupergraph, nameInSupergraph === specInSupergraph.url.name ? undefined : nameInSupergraph, specInSupergraph.defaultCorePurpose);
344367
assert(errors.length === 0, "We shouldn't have errors adding the join spec to the (still empty) supergraph schema");
345368
const argumentsMerger = compositionSpec.argumentsMerger?.call(null, this.merged);
346369
if (argumentsMerger instanceof GraphQLError) {
@@ -395,7 +418,7 @@ class Merger {
395418
this.addDirectivesShallow();
396419

397420
const typesToMerge = this.merged.types()
398-
.filter((type) => !linkSpec.isSpecType(type) && !joinSpec.isSpecType(type));
421+
.filter((type) => !this.linkSpec.isSpecType(type) && !this.joinSpec.isSpecType(type));
399422

400423
// Then, for object and interface types, we merge the 'implements' relationship, and we merge the unions.
401424
// We do this first because being able to know if a type is a subtype of another one (which relies on those
@@ -426,7 +449,7 @@ class Merger {
426449

427450
for (const definition of this.merged.directives()) {
428451
// we should skip the supergraph specific directives, that is the @core and @join directives.
429-
if (linkSpec.isSpecDirective(definition) || joinSpec.isSpecDirective(definition)) {
452+
if (this.linkSpec.isSpecDirective(definition) || this.joinSpec.isSpecDirective(definition)) {
430453
continue;
431454
}
432455
this.mergeDirectiveDefinition(this.subgraphsSchema.map(s => s.directive(definition.name)), definition);
@@ -622,7 +645,7 @@ class Merger {
622645

623646
private mergeImplements<T extends ObjectType | InterfaceType>(sources: (T | undefined)[], dest: T) {
624647
const implemented = new Set<string>();
625-
const joinImplementsDirective = joinSpec.implementsDirective(this.merged)!;
648+
const joinImplementsDirective = this.joinSpec.implementsDirective(this.merged)!;
626649
for (const [idx, source] of sources.entries()) {
627650
if (source) {
628651
const name = this.joinSpecName(idx);
@@ -753,7 +776,7 @@ class Merger {
753776
}
754777

755778
private addJoinType(sources: (NamedType | undefined)[], dest: NamedType) {
756-
const joinTypeDirective = joinSpec.typeDirective(this.merged);
779+
const joinTypeDirective = this.joinSpec.typeDirective(this.merged);
757780
for (const [idx, source] of sources.entries()) {
758781
if (!source) {
759782
continue;
@@ -914,7 +937,7 @@ class Merger {
914937
// clarify to the later extraction process that this particular field doesn't come
915938
// from any particular subgraph (it comes indirectly from an @interfaceObject type,
916939
// but it's very much indirect so ...).
917-
implemField.applyDirective(joinSpec.fieldDirective(this.merged), { graph: undefined });
940+
implemField.applyDirective(this.joinSpec.fieldDirective(this.merged), { graph: undefined });
918941

919942

920943
// If we had to add a field here, it means that, for this particular implementation, the
@@ -937,7 +960,7 @@ class Merger {
937960
// implementation type when @interfaceObject is used. But we shouldn't copy the `join` spec directive
938961
// as those are for the interface field but are invalid for the implementation field.
939962
source.appliedDirectives.forEach((d) => {
940-
if (!joinSpec.isSpecDirective(d.definition!)) {
963+
if (!this.joinSpec.isSpecDirective(d.definition!)) {
941964
dest.applyDirective(d.name, {...d.arguments()})
942965
}
943966
});
@@ -1537,7 +1560,7 @@ class Merger {
15371560
})) {
15381561
return;
15391562
}
1540-
const joinFieldDirective = joinSpec.fieldDirective(this.merged);
1563+
const joinFieldDirective = this.joinSpec.fieldDirective(this.merged);
15411564
for (const [idx, source] of sources.entries()) {
15421565
const usedOverridden = mergeContext.isUsedOverridden(idx);
15431566
const unusedOverridden = mergeContext.isUnusedOverridden(idx);
@@ -1892,7 +1915,7 @@ class Merger {
18921915
}
18931916

18941917
private addJoinUnionMember(sources: (UnionType | undefined)[], dest: UnionType, member: ObjectType) {
1895-
const joinUnionMemberDirective = joinSpec.unionMemberDirective(this.merged);
1918+
const joinUnionMemberDirective = this.joinSpec.unionMemberDirective(this.merged);
18961919
// We should always be merging with the latest join spec, so this should exists, but well, in prior versions where
18971920
// the directive didn't existed, we simply did had any replacement so ...
18981921
if (!joinUnionMemberDirective) {
@@ -1989,7 +2012,7 @@ class Merger {
19892012
this.recordAppliedDirectivesToMerge(valueSources, value);
19902013
this.addJoinEnumValue(valueSources, value);
19912014

1992-
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(inaccessibleSpec.inaccessibleDirectiveSpec.name);
2015+
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(this.inaccessibleSpec.inaccessibleDirectiveSpec.name);
19932016
const isInaccessible = inaccessibleInSupergraph && value.hasAppliedDirective(inaccessibleInSupergraph.definition);
19942017
// The merging strategy depends on the enum type usage:
19952018
// - if it is _only_ used in position of Input type, we merge it with an "intersection" strategy (like other input types/things).
@@ -2038,7 +2061,7 @@ class Merger {
20382061
}
20392062

20402063
private addJoinEnumValue(sources: (EnumValue | undefined)[], dest: EnumValue) {
2041-
const joinEnumValueDirective = joinSpec.enumValueDirective(this.merged);
2064+
const joinEnumValueDirective = this.joinSpec.enumValueDirective(this.merged);
20422065
// We should always be merging with the latest join spec, so this should exists, but well, in prior versions where
20432066
// the directive didn't existed, we simply did had any replacement so ...
20442067
if (!joinEnumValueDirective) {
@@ -2080,7 +2103,7 @@ class Merger {
20802103
}
20812104

20822105
private mergeInput(sources: (InputObjectType | undefined)[], dest: InputObjectType) {
2083-
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(inaccessibleSpec.inaccessibleDirectiveSpec.name);
2106+
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(this.inaccessibleSpec.inaccessibleDirectiveSpec.name);
20842107

20852108
// Like for other inputs, we add all the fields found in any subgraphs initially as a simple mean to have a complete list of
20862109
// field to iterate over, but we will remove those that are not in all subgraphs.
@@ -2355,7 +2378,7 @@ class Merger {
23552378
// is @inaccessible, which is necessary to exist in the supergraph for EnumValues to properly
23562379
// determine whether the fact that a value is both input / output will matter
23572380
private recordAppliedDirectivesToMerge(sources: (SchemaElement<any, any> | undefined)[], dest: SchemaElement<any, any>) {
2358-
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(inaccessibleSpec.inaccessibleDirectiveSpec.name);
2381+
const inaccessibleInSupergraph = this.mergedFederationDirectiveInSupergraph.get(this.inaccessibleSpec.inaccessibleDirectiveSpec.name);
23592382
const inaccessibleName = inaccessibleInSupergraph?.definition.name;
23602383
const names = this.gatherAppliedDirectiveNames(sources);
23612384

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import gql from "graphql-tag";
33
import { buildSubgraph } from "../federation";
44
import { assert } from "../utils";
55
import { buildSchemaFromAST } from "../buildSchema";
6-
import { removeAllCoreFeatures } from "../coreSpec";
6+
import { removeAllCoreFeatures, FeatureDefinitions, FeatureVersion, FeatureDefinition, FeatureUrl } from "../coreSpec";
77
import { errorCauses } from "../error";
88

99
function expectErrors(
@@ -210,3 +210,36 @@ describe('removeAllCoreFeatures', () => {
210210
expect(schema.elementByCoordinate("@foo__quz")).toBeUndefined();
211211
});
212212
});
213+
214+
class TestFeatureDefinition extends FeatureDefinition {
215+
constructor(version: FeatureVersion, fedVersion?: FeatureVersion) {
216+
super(new FeatureUrl('test', 'test', version), fedVersion);
217+
}
218+
}
219+
220+
describe('getMinimumRequiredVersion tests', () => {
221+
it('various combinations', () => {
222+
const versions = new FeatureDefinitions<TestFeatureDefinition>('test')
223+
.add(new TestFeatureDefinition(new FeatureVersion(0, 1)))
224+
.add(new TestFeatureDefinition(new FeatureVersion(0, 2), new FeatureVersion(1, 0)))
225+
.add(new TestFeatureDefinition(new FeatureVersion(0, 3), new FeatureVersion(2,0)))
226+
.add(new TestFeatureDefinition(new FeatureVersion(0, 4), new FeatureVersion(2,1)))
227+
.add(new TestFeatureDefinition(new FeatureVersion(0, 5), new FeatureVersion(2,2)));
228+
229+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(0, 1)).version).toEqual(new FeatureVersion(0, 1));
230+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(1, 0)).version).toEqual(new FeatureVersion(0, 2));
231+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(1, 1)).version).toEqual(new FeatureVersion(0, 2));
232+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(2, 0)).version).toEqual(new FeatureVersion(0, 3));
233+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(2, 1)).version).toEqual(new FeatureVersion(0, 4));
234+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(2, 2)).version).toEqual(new FeatureVersion(0, 5));
235+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(2, 3)).version).toEqual(new FeatureVersion(0, 5));
236+
237+
// now add a new major version and test again. All previous version should be forced to the new major
238+
versions.add(new TestFeatureDefinition(new FeatureVersion(1, 0), new FeatureVersion(2, 4)));
239+
versions.add(new TestFeatureDefinition(new FeatureVersion(1, 1), new FeatureVersion(2, 5)));
240+
241+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(2, 3)).version).toEqual(new FeatureVersion(1, 0));
242+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(2, 4)).version).toEqual(new FeatureVersion(1, 0));
243+
expect(versions.getMinimumRequiredVersion(new FeatureVersion(2, 5)).version).toEqual(new FeatureVersion(1, 1));
244+
})
245+
})

internals-js/src/coreSpec.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ASTNode, DirectiveLocation, GraphQLError, StringValueNode } from "graph
22
import { URL } from "url";
33
import { CoreFeature, Directive, DirectiveDefinition, EnumType, ErrGraphQLAPISchemaValidationFailed, ErrGraphQLValidationFailed, InputType, ListType, NamedType, NonNullType, ScalarType, Schema, SchemaDefinition, SchemaElement, sourceASTs } from "./definitions";
44
import { sameType } from "./types";
5-
import { assert, firstOf, MapWithCachedArrays } from './utils';
5+
import { assert, findLast, firstOf, MapWithCachedArrays } from './utils';
66
import { aggregateError, ERRORS } from "./error";
77
import { valueToString } from "./values";
88
import { coreFeatureDefinitionIfKnown, registerKnownFeature } from "./knownCoreFeatures";
@@ -41,7 +41,8 @@ export abstract class FeatureDefinition {
4141
private readonly _directiveSpecs = new MapWithCachedArrays<string, DirectiveSpecification>();
4242
private readonly _typeSpecs = new MapWithCachedArrays<string, TypeSpecification>();
4343

44-
constructor(url: FeatureUrl | string) {
44+
// A minimumFederationVersion that's undefined would mean that we won't produce that version in the supergraph SDL.
45+
constructor(url: FeatureUrl | string, readonly minimumFederationVersion?: FeatureVersion) {
4546
this.url = typeof url === 'string' ? FeatureUrl.parse(url) : url;
4647
}
4748

@@ -364,8 +365,8 @@ const linkImportTypeSpec = createScalarTypeSpecification({ name: 'Import' });
364365
export class CoreSpecDefinition extends FeatureDefinition {
365366
private readonly directiveDefinitionSpec: DirectiveSpecification;
366367

367-
constructor(version: FeatureVersion, identity: string = linkIdentity, name: string = linkDirectiveDefaultName) {
368-
super(new FeatureUrl(identity, name, version));
368+
constructor(version: FeatureVersion, minimumFederationVersion?: FeatureVersion, identity: string = linkIdentity, name: string = linkDirectiveDefaultName) {
369+
super(new FeatureUrl(identity, name, version), minimumFederationVersion);
369370
this.directiveDefinitionSpec = createDirectiveSpecification({
370371
name,
371372
locations: [DirectiveLocation.SCHEMA],
@@ -587,6 +588,23 @@ export class FeatureDefinitions<T extends FeatureDefinition = FeatureDefinition>
587588
assert(this._definitions.length > 0, 'Trying to get latest when no definitions exist');
588589
return this._definitions[0];
589590
}
591+
592+
getMinimumRequiredVersion(fedVersion: FeatureVersion): T {
593+
// this._definitions is already sorted with the most recent first
594+
// get the first definition that is compatible with the federation version
595+
// if the minimum version is not present, assume that we won't look for an older version
596+
const def = this._definitions.find(def => def.minimumFederationVersion ? fedVersion >= def.minimumFederationVersion : true);
597+
assert(def, `No compatible definition exists for federation version ${fedVersion}`);
598+
599+
// note that it's necessary that we can only get versions that have the same major version as the latest,
600+
// because otherwise we can not guarantee compatibility. In this case, we want to return the oldest version with
601+
// the same major version as the latest.
602+
const latestMajor = this.latest().version.major;
603+
if (def.version.major !== latestMajor) {
604+
return findLast(this._definitions, def => def.version.major === latestMajor) ?? this.latest();
605+
}
606+
return def;
607+
}
590608
}
591609

592610
/**
@@ -789,11 +807,11 @@ export function findCoreSpecVersion(featureUrl: FeatureUrl): CoreSpecDefinition
789807
}
790808

791809
export const CORE_VERSIONS = new FeatureDefinitions<CoreSpecDefinition>(coreIdentity)
792-
.add(new CoreSpecDefinition(new FeatureVersion(0, 1), coreIdentity, 'core'))
793-
.add(new CoreSpecDefinition(new FeatureVersion(0, 2), coreIdentity, 'core'));
810+
.add(new CoreSpecDefinition(new FeatureVersion(0, 1), undefined, coreIdentity, 'core'))
811+
.add(new CoreSpecDefinition(new FeatureVersion(0, 2), new FeatureVersion(2, 0), coreIdentity, 'core'));
794812

795813
export const LINK_VERSIONS = new FeatureDefinitions<CoreSpecDefinition>(linkIdentity)
796-
.add(new CoreSpecDefinition(new FeatureVersion(1, 0)));
814+
.add(new CoreSpecDefinition(new FeatureVersion(1, 0), new FeatureVersion(2, 0)));
797815

798816
registerKnownFeature(CORE_VERSIONS);
799817
registerKnownFeature(LINK_VERSIONS);

internals-js/src/directiveAndTypeSpecification.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { valueEquals, valueToString } from "./values";
2222
import { sameType } from "./types";
2323
import { arrayEquals, assert } from "./utils";
2424
import { ArgumentCompositionStrategy } from "./argumentCompositionStrategies";
25-
import { FeatureDefinition } from "./coreSpec";
25+
import { FeatureDefinition, FeatureVersion } from "./coreSpec";
2626

2727
export type DirectiveSpecification = {
2828
name: string,
@@ -31,7 +31,7 @@ export type DirectiveSpecification = {
3131
}
3232

3333
export type DirectiveCompositionSpecification = {
34-
supergraphSpecification: () => FeatureDefinition,
34+
supergraphSpecification: (federationVersion: FeatureVersion) => FeatureDefinition,
3535
argumentsMerger?: (schema: Schema) => ArgumentMerger | GraphQLError,
3636
}
3737

@@ -80,7 +80,7 @@ export function createDirectiveSpecification({
8080
repeatable?: boolean,
8181
args?: DirectiveArgumentSpecification[],
8282
composes?: boolean,
83-
supergraphSpecification?: () => FeatureDefinition,
83+
supergraphSpecification?: (fedVersion: FeatureVersion) => FeatureDefinition,
8484
}): DirectiveSpecification {
8585
let composition: DirectiveCompositionSpecification | undefined = undefined;
8686
if (composes) {

internals-js/src/federationSpec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export class FederationSpecDefinition extends FeatureDefinition {
116116
repeatable: version >= (new FeatureVersion(2, 2)),
117117
}));
118118

119-
this.registerDirective(INACCESSIBLE_VERSIONS.latest().inaccessibleDirectiveSpec);
119+
this.registerDirective(INACCESSIBLE_VERSIONS.getMinimumRequiredVersion(version).inaccessibleDirectiveSpec);
120120

121121
this.registerDirective(createDirectiveSpecification({
122122
name: FederationDirectiveName.OVERRIDE,

0 commit comments

Comments
 (0)