Skip to content

Commit e5f98c2

Browse files
fix: support linked, repeatable, federation directives (#7249)
* fix: support linked, repeatable, federation directives * chore(dependencies): updated changesets for modified dependencies --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 3d98a4e commit e5f98c2

File tree

10 files changed

+284
-67
lines changed

10 files changed

+284
-67
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@graphql-tools/merge": patch
3+
---
4+
dependencies updates:
5+
- Added dependency [`@theguild/federation-composition@^0.16.0` ↗︎](https://www.npmjs.com/package/@theguild/federation-composition/v/0.16.0) (to `dependencies`)

.changeset/hungry-schools-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-tools/merge': minor
3+
---
4+
5+
Support repeatable @link-ed federation directives; fix merging non-identical, repeatable directives
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
extend schema
2+
@link(url: "https://specs.apollo.dev/link/v1.0")
3+
@link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"])
4+
5+
type Item @key(fields: "id") @key(fields: "id type") {
6+
id: ID!
7+
type: String!
8+
}

packages/import/tests/schema/import-schema.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,20 @@ describe('importSchema', () => {
485485
expect(importSchema('./fixtures/directive/h.graphql')).toBeSimilarGqlDoc(expectedSDL);
486486
});
487487

488+
test('importSchema: has context for which federated directives are repeatable', () => {
489+
const expectedSDL = /* GraphQL */ `
490+
extend schema
491+
@link(url: "https://specs.apollo.dev/link/v1.0")
492+
@link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"])
493+
494+
type Item @key(fields: "id") @key(fields: "id type") {
495+
id: ID!
496+
type: String!
497+
}
498+
`;
499+
expect(importSchema('./fixtures/directive/i.graphql')).toBeSimilarGqlDoc(expectedSDL);
500+
});
501+
488502
test('importSchema: interfaces', () => {
489503
const expectedSDL = /* GraphQL */ `
490504
type A implements B {

packages/merge/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
},
5353
"dependencies": {
5454
"@graphql-tools/utils": "^10.8.6",
55+
"@theguild/federation-composition": "^0.16.0",
5556
"tslib": "^2.4.0"
5657
},
5758
"devDependencies": {

packages/merge/src/typedefs-mergers/directives.ts

Lines changed: 84 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,85 +2,103 @@ import {
22
ArgumentNode,
33
DirectiveDefinitionNode,
44
DirectiveNode,
5+
Kind,
56
ListValueNode,
67
NameNode,
8+
ValueNode,
79
} from 'graphql';
8-
import { isSome } from '@graphql-tools/utils';
910
import { Config } from './merge-typedefs.js';
1011

11-
function directiveAlreadyExists(
12-
directivesArr: ReadonlyArray<DirectiveNode>,
13-
otherDirective: DirectiveNode,
14-
): boolean {
15-
return !!directivesArr.find(directive => directive.name.value === otherDirective.name.value);
16-
}
17-
1812
function isRepeatableDirective(
1913
directive: DirectiveNode,
2014
directives?: Record<string, DirectiveDefinitionNode>,
15+
repeatableLinkImports?: Set<string>,
2116
): boolean {
22-
return !!directives?.[directive.name.value]?.repeatable;
17+
return !!(
18+
directives?.[directive.name.value]?.repeatable ??
19+
repeatableLinkImports?.has(directive.name.value)
20+
);
2321
}
2422

2523
function nameAlreadyExists(name: NameNode, namesArr: ReadonlyArray<NameNode>): boolean {
2624
return namesArr.some(({ value }) => value === name.value);
2725
}
2826

2927
function mergeArguments(a1: readonly ArgumentNode[], a2: readonly ArgumentNode[]): ArgumentNode[] {
30-
const result: ArgumentNode[] = [...a2];
28+
const result: ArgumentNode[] = [];
3129

32-
for (const argument of a1) {
30+
for (const argument of [...a2, ...a1]) {
3331
const existingIndex = result.findIndex(a => a.name.value === argument.name.value);
3432

35-
if (existingIndex > -1) {
33+
if (existingIndex === -1) {
34+
result.push(argument);
35+
} else {
3636
const existingArg = result[existingIndex];
3737

3838
if (existingArg.value.kind === 'ListValue') {
3939
const source = (existingArg.value as any).values;
4040
const target = (argument.value as ListValueNode).values;
4141

4242
// merge values of two lists
43-
(existingArg.value as any).values = deduplicateLists(
44-
source,
45-
target,
46-
(targetVal, source) => {
43+
(existingArg.value as ListValueNode) = {
44+
...existingArg.value,
45+
values: deduplicateLists(source, target, (targetVal, source) => {
4746
const value = (targetVal as any).value;
4847
return !value || !source.some((sourceVal: any) => sourceVal.value === value);
49-
},
50-
);
48+
}),
49+
};
5150
} else {
5251
(existingArg as any).value = argument.value;
5352
}
54-
} else {
55-
result.push(argument);
5653
}
5754
}
5855

5956
return result;
6057
}
6158

62-
function deduplicateDirectives(
63-
directives: ReadonlyArray<DirectiveNode>,
64-
definitions?: Record<string, DirectiveDefinitionNode>,
65-
): DirectiveNode[] {
66-
return directives
67-
.map((directive, i, all) => {
68-
const firstAt = all.findIndex(d => d.name.value === directive.name.value);
69-
70-
if (firstAt !== i && !isRepeatableDirective(directive, definitions)) {
71-
const dup = all[firstAt];
72-
73-
(directive as any).arguments = mergeArguments(
74-
directive.arguments as any,
75-
dup.arguments as any,
59+
const matchValues = (a: ValueNode, b: ValueNode): boolean => {
60+
if (a.kind === b.kind) {
61+
switch (a.kind) {
62+
case Kind.LIST:
63+
return (
64+
a.values.length === (b as typeof a).values.length &&
65+
a.values.every(aVal => (b as typeof a).values.find(bVal => matchValues(aVal, bVal)))
7666
);
77-
return null;
78-
}
79-
80-
return directive;
81-
})
82-
.filter(isSome);
83-
}
67+
case Kind.VARIABLE:
68+
case Kind.NULL:
69+
return true;
70+
case Kind.OBJECT:
71+
return (
72+
a.fields.length === (b as typeof a).fields.length &&
73+
a.fields.every(aField =>
74+
(b as typeof a).fields.find(
75+
bField =>
76+
aField.name.value === bField.name.value && matchValues(aField.value, bField.value),
77+
),
78+
)
79+
);
80+
default:
81+
return a.value === (b as typeof a).value;
82+
}
83+
}
84+
return false;
85+
};
86+
87+
const matchArguments = (a: ArgumentNode, b: ArgumentNode): boolean =>
88+
a.name.value === b.name.value && a.value.kind === b.value.kind && matchValues(a.value, b.value);
89+
90+
/**
91+
* Check if a directive is an exact match of another directive based on their
92+
* arguments.
93+
*/
94+
const matchDirectives = (a: DirectiveNode, b: DirectiveNode): boolean => {
95+
const matched =
96+
a.name.value === b.name.value &&
97+
(a.arguments === b.arguments ||
98+
(a.arguments?.length === b.arguments?.length &&
99+
a.arguments?.every(argA => b.arguments?.find(argB => matchArguments(argA, argB)))));
100+
return !!matched;
101+
};
84102

85103
export function mergeDirectives(
86104
d1: ReadonlyArray<DirectiveNode> = [],
@@ -91,21 +109,32 @@ export function mergeDirectives(
91109
const reverseOrder: boolean | undefined = config && config.reverseDirectives;
92110
const asNext = reverseOrder ? d1 : d2;
93111
const asFirst = reverseOrder ? d2 : d1;
94-
const result = deduplicateDirectives([...asNext], directives);
95-
96-
for (const directive of asFirst) {
97-
if (
98-
directiveAlreadyExists(result, directive) &&
99-
!isRepeatableDirective(directive, directives)
100-
) {
101-
const existingDirectiveIndex = result.findIndex(d => d.name.value === directive.name.value);
102-
const existingDirective = result[existingDirectiveIndex];
103-
(result[existingDirectiveIndex] as any).arguments = mergeArguments(
104-
directive.arguments || [],
105-
existingDirective.arguments || [],
106-
);
112+
const result: DirectiveNode[] = [];
113+
for (const directive of [...asNext, ...asFirst]) {
114+
if (isRepeatableDirective(directive, directives, config?.repeatableLinkImports)) {
115+
// look for repeated, identical directives that come before this instance
116+
// if those exist, return null so that this directive gets removed.
117+
const exactDuplicate = result.find(d => matchDirectives(directive, d));
118+
if (!exactDuplicate) {
119+
result.push(directive);
120+
}
107121
} else {
108-
result.push(directive);
122+
const firstAt = result.findIndex(d => d.name.value === directive.name.value);
123+
if (firstAt === -1) {
124+
// if did not find a directive with this name on the result set already
125+
result.push(directive);
126+
} else {
127+
// if not repeatable and found directive with the same name already in the result set,
128+
// then merge the arguments of the existing directive and the new directive
129+
const mergedArguments = mergeArguments(
130+
directive.arguments ?? [],
131+
result[firstAt].arguments ?? [],
132+
);
133+
result[firstAt] = {
134+
...result[firstAt],
135+
arguments: mergedArguments.length === 0 ? undefined : mergedArguments,
136+
};
137+
}
109138
}
110139
}
111140

packages/merge/src/typedefs-mergers/merge-typedefs.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
resetComments,
1919
TypeSource,
2020
} from '@graphql-tools/utils';
21+
import { extractLinkImplementations } from '@theguild/federation-composition';
2122
import { OnFieldTypeConflict } from './fields.js';
2223
import { mergeGraphQLNodes, schemaDefSymbol } from './merge-nodes.js';
2324
import { DEFAULT_OPERATION_TYPE_NAME_MAP } from './schema-def.js';
@@ -77,6 +78,11 @@ export interface Config extends ParseOptions, GetDocumentNodeFromSchemaOptions {
7778
convertExtensions?: boolean;
7879
consistentEnumMerge?: boolean;
7980
ignoreFieldConflicts?: boolean;
81+
/**
82+
* Allow directives that are not defined in the schema, but are imported
83+
* through federated @links, to be repeated.
84+
*/
85+
repeatableLinkImports?: Set<string>;
8086
/**
8187
* Called if types of the same fields are different
8288
*
@@ -145,14 +151,33 @@ function visitTypeSources(
145151
allDirectives: DirectiveDefinitionNode[] = [],
146152
allNodes: DefinitionNode[] = [],
147153
visitedTypeSources = new Set<TypeSource>(),
154+
repeatableLinkImports: Set<string> = new Set(),
148155
) {
156+
const addRepeatable = (name: string) => {
157+
repeatableLinkImports.add(name);
158+
};
159+
149160
if (typeSource && !visitedTypeSources.has(typeSource)) {
150161
visitedTypeSources.add(typeSource);
151162
if (typeof typeSource === 'function') {
152-
visitTypeSources(typeSource(), options, allDirectives, allNodes, visitedTypeSources);
163+
visitTypeSources(
164+
typeSource(),
165+
options,
166+
allDirectives,
167+
allNodes,
168+
visitedTypeSources,
169+
repeatableLinkImports,
170+
);
153171
} else if (Array.isArray(typeSource)) {
154172
for (const type of typeSource) {
155-
visitTypeSources(type, options, allDirectives, allNodes, visitedTypeSources);
173+
visitTypeSources(
174+
type,
175+
options,
176+
allDirectives,
177+
allNodes,
178+
visitedTypeSources,
179+
repeatableLinkImports,
180+
);
156181
}
157182
} else if (isSchema(typeSource)) {
158183
const documentNode = getDocumentNodeFromSchema(typeSource, options);
@@ -162,6 +187,7 @@ function visitTypeSources(
162187
allDirectives,
163188
allNodes,
164189
visitedTypeSources,
190+
repeatableLinkImports,
165191
);
166192
} else if (isStringTypes(typeSource) || isSourceTypes(typeSource)) {
167193
const documentNode = parse(typeSource, options);
@@ -171,8 +197,31 @@ function visitTypeSources(
171197
allDirectives,
172198
allNodes,
173199
visitedTypeSources,
200+
repeatableLinkImports,
174201
);
175202
} else if (typeof typeSource === 'object' && isDefinitionNode(typeSource)) {
203+
const { matchesImplementation, resolveImportName } = extractLinkImplementations({
204+
definitions: [typeSource],
205+
kind: Kind.DOCUMENT,
206+
});
207+
const federationUrl = 'https://specs.apollo.dev/federation';
208+
const linkUrl = 'https://specs.apollo.dev/link';
209+
210+
/**
211+
* Official Federated imports are special because they can be referenced without specifyin the import.
212+
* To handle this case, we must prepare a list of all the possible valid usages to check against.
213+
* Note that this versioning is not technically correct, since some definitions are after v2.0.
214+
* But this is enough information to be comfortable not blocking the imports at this phase. It's
215+
* the job of the composer to validate the versions.
216+
* */
217+
if (matchesImplementation(federationUrl, 'v2.0')) {
218+
addRepeatable(resolveImportName(federationUrl, '@composeDirective'));
219+
addRepeatable(resolveImportName(federationUrl, '@key'));
220+
}
221+
if (matchesImplementation(linkUrl, 'v1.0')) {
222+
addRepeatable(resolveImportName(linkUrl, '@link'));
223+
}
224+
176225
if (typeSource.kind === Kind.DIRECTIVE_DEFINITION) {
177226
allDirectives.push(typeSource);
178227
} else {
@@ -185,26 +234,29 @@ function visitTypeSources(
185234
allDirectives,
186235
allNodes,
187236
visitedTypeSources,
237+
repeatableLinkImports,
188238
);
189239
} else {
190240
throw new Error(
191241
`typeDefs must contain only strings, documents, schemas, or functions, got ${typeof typeSource}`,
192242
);
193243
}
194244
}
195-
return { allDirectives, allNodes };
245+
return { allDirectives, allNodes, repeatableLinkImports };
196246
}
197247

198248
export function mergeGraphQLTypes(typeSource: TypeSource, config: Config): DefinitionNode[] {
199249
resetComments();
200250

201-
const { allDirectives, allNodes } = visitTypeSources(typeSource, config);
251+
const { allDirectives, allNodes, repeatableLinkImports } = visitTypeSources(typeSource, config);
202252

203253
const mergedDirectives = mergeGraphQLNodes(allDirectives, config) as Record<
204254
string,
205255
DirectiveDefinitionNode
206256
>;
207257

258+
config.repeatableLinkImports = repeatableLinkImports;
259+
208260
const mergedNodes = mergeGraphQLNodes(allNodes, config, mergedDirectives);
209261

210262
if (config?.useSchemaDefinition) {

packages/merge/src/typedefs-mergers/schema-def.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function mergeOperationTypes(
3232

3333
export function mergeSchemaDefs(
3434
node: SchemaDefinitionNode | SchemaExtensionNode,
35-
existingNode: SchemaDefinitionNode | SchemaExtensionNode,
35+
existingNode: SchemaDefinitionNode | SchemaExtensionNode | undefined,
3636
config?: Config,
3737
directives?: Record<string, DirectiveDefinitionNode>,
3838
): SchemaDefinitionNode | SchemaExtensionNode {

0 commit comments

Comments
 (0)