diff --git a/.changeset/cold-keys-dream.md b/.changeset/cold-keys-dream.md new file mode 100644 index 0000000..89e8393 --- /dev/null +++ b/.changeset/cold-keys-dream.md @@ -0,0 +1,5 @@ +--- +'@theguild/federation-composition': patch +--- + +Visit every field in provides and requires directives diff --git a/.changeset/heavy-steaks-agree.md b/.changeset/heavy-steaks-agree.md new file mode 100644 index 0000000..6142083 --- /dev/null +++ b/.changeset/heavy-steaks-agree.md @@ -0,0 +1,5 @@ +--- +'@theguild/federation-composition': patch +--- + +Fix unnecessary join\_\_field(override:) on Query fields when it points to non-existing subgraph diff --git a/.changeset/rich-sheep-hide.md b/.changeset/rich-sheep-hide.md new file mode 100644 index 0000000..78a7bc3 --- /dev/null +++ b/.changeset/rich-sheep-hide.md @@ -0,0 +1,5 @@ +--- +'@theguild/federation-composition': patch +--- + +Deduplicate composed directives diff --git a/.changeset/silly-parents-smash.md b/.changeset/silly-parents-smash.md new file mode 100644 index 0000000..a43860c --- /dev/null +++ b/.changeset/silly-parents-smash.md @@ -0,0 +1,5 @@ +--- +'@theguild/federation-composition': patch +--- + +Remove duplicated link spec definitions diff --git a/.changeset/thin-vans-chew.md b/.changeset/thin-vans-chew.md new file mode 100644 index 0000000..063269c --- /dev/null +++ b/.changeset/thin-vans-chew.md @@ -0,0 +1,5 @@ +--- +'@theguild/federation-composition': patch +--- + +Drop unused fields marked with @external only in a single type in Fed v1 diff --git a/__tests__/composition.spec.ts b/__tests__/composition.spec.ts index 5f47fa8..a43ec92 100644 --- a/__tests__/composition.spec.ts +++ b/__tests__/composition.spec.ts @@ -6661,4 +6661,215 @@ testImplementations(api => { } `); }); + + test('print join__field external the field is required in a deeply nested selection set', () => { + const result = api.composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + type Query { + a: String + } + + type User { + id: ID! + age: Int! + name: String! + } + `), + }, + { + name: 'b', + typeDefs: parse(/* GraphQL */ ` + type Query { + b: String + } + + type Book { + author: User @requires(fields: "author { name }") + } + + extend type User { + name: String! @external + } + `), + }, + ]); + + assertCompositionSuccess(result); + + expect(result.supergraphSdl).toContainGraphQL(/* GraphQL */ ` + type User @join__type(graph: A) @join__type(graph: B) { + name: String! @join__field(external: true, graph: B) @join__field(graph: A) + id: ID! @join__field(graph: A) + age: Int! @join__field(graph: A) + } + `); + }); + + test('Query field with @override that points to non-existing subgraph', () => { + let result = api.composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@override"]) + + type Query { + a: String @override(from: "non-existing") + } + `), + }, + ]); + assertCompositionSuccess(result); + + expect(result.supergraphSdl).toContainGraphQL(/* GraphQL */ ` + type Query @join__type(graph: A) { + a: String + } + `); + + result = api.composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@override"]) + + type Query { + a: String @override(from: "non-existing") + } + `), + }, + { + name: 'b', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@override"]) + + type Query { + b: String + } + `), + }, + ]); + assertCompositionSuccess(result); + + expect(result.supergraphSdl).toContainGraphQL(/* GraphQL */ ` + type Query @join__type(graph: A) @join__type(graph: B) { + a: String @join__field(graph: A, override: "non-existing") + b: String @join__field(graph: B) + } + `); + }); + + test('drop unused external fields from Federation v1 subgraphs', () => { + const result = api.composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + type User @key(fields: "id") { + id: ID! + name: String @external + age: Int! + } + + type Query { + a: String + } + `), + }, + { + name: 'b', + typeDefs: parse(/* GraphQL */ ` + type User @key(fields: "id") { + id: ID! + age: Int! @external + birthday: String @requires(fields: "age") + } + + type Query { + b: String + } + `), + }, + ]); + + assertCompositionSuccess(result); + + // No User.name, it's dropped + expect(result.supergraphSdl).toContainGraphQL(/* GraphQL */ ` + type User @join__type(graph: A, key: "id") @join__type(graph: B, key: "id") { + id: ID! + age: Int! @join__field(external: true, graph: B) @join__field(graph: A) + birthday: String @join__field(graph: B, requires: "age") + } + `); + }); + + test('deduplicates directives', () => { + const result = api.composeServices([ + { + name: 'a', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@composeDirective"] + ) + @link(url: "https://myspecs.dev/lowercase/v1.0", import: ["@lowercase"]) + @composeDirective(name: "@lowercase") + + directive @lowercase on FIELD_DEFINITION + + type User @key(fields: "id") { + id: ID! @lowercase + age: Int! + } + + type Query { + a: String + } + `), + }, + { + name: 'b', + typeDefs: parse(/* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@requires", "@composeDirective"] + ) + @link(url: "https://myspecs.dev/lowercase/v1.0", import: ["@lowercase"]) + @composeDirective(name: "@lowercase") + + directive @lowercase on FIELD_DEFINITION + + type User @key(fields: "id") { + id: ID! @lowercase + age: Int! @external + birthday: String @requires(fields: "age") + } + + type Query { + b: String + } + `), + }, + ]); + + assertCompositionSuccess(result); + + // No User.name, it's dropped + expect(result.supergraphSdl).toContainGraphQL(/* GraphQL */ ` + type User @join__type(graph: A, key: "id") @join__type(graph: B, key: "id") { + id: ID! @lowercase + age: Int! @join__field(external: true, graph: B) @join__field(graph: A) + birthday: String @join__field(graph: B, requires: "age") + } + `); + }); }); diff --git a/__tests__/subgraph/link-directive.spec.ts b/__tests__/subgraph/link-directive.spec.ts index 6adcdce..78f4159 100644 --- a/__tests__/subgraph/link-directive.spec.ts +++ b/__tests__/subgraph/link-directive.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { graphql, testVersions } from '../shared/testkit.js'; +import { assertCompositionSuccess, graphql, testVersions } from '../shared/testkit.js'; testVersions((api, version) => { test('INVALID_LINK_IDENTIFIER', () => { @@ -286,4 +286,42 @@ testVersions((api, version) => { }), ); }); + + test('remove duplicated link spec definitions', () => { + assertCompositionSuccess( + api.composeServices([ + { + name: 'users', + typeDefs: graphql` + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link( + url: "https://specs.apollo.dev/federation/${version}" + import: ["@shareable"] + ) { + query: Query + } + + directive @link( + url: String! + as: String + for: link__Purpose + import: [link__Import] + ) repeatable on SCHEMA + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + + type Query { + foo: String + } + `, + }, + ]), + ); + }); }); diff --git a/src/subgraph/helpers.ts b/src/subgraph/helpers.ts index 8c9983b..8eea1f6 100644 --- a/src/subgraph/helpers.ts +++ b/src/subgraph/helpers.ts @@ -248,7 +248,9 @@ export function visitFields({ break; } - if (interceptDirective && selection.directives?.length) { + const isTypename = selection.name.value === '__typename'; + + if (!isTypename && interceptDirective && selection.directives?.length) { for (const directive of selection.directives) { interceptDirective({ directiveName: directive.name.value, @@ -257,21 +259,23 @@ export function visitFields({ } } - context.markAsUsed( - 'fields', - typeDefinition.kind, - typeDefinition.name.value, - selectionFieldDef.name.value, - ); + if (!isTypename) { + context.markAsUsed( + 'fields', + typeDefinition.kind, + typeDefinition.name.value, + selectionFieldDef.name.value, + ); + } - if (interceptField) { + if (!isTypename && interceptField) { interceptField({ typeDefinition, fieldName: selection.name.value, }); } - if (selectionFieldDef.arguments?.length && interceptArguments) { + if (!isTypename && selectionFieldDef.arguments?.length && interceptArguments) { interceptArguments({ typeDefinition, fieldName: selection.name.value, @@ -279,7 +283,7 @@ export function visitFields({ continue; } - if (interceptNonExternalField || interceptExternalField) { + if (!isTypename && (interceptNonExternalField || interceptExternalField)) { const isExternal = selectionFieldDef.directives?.some(d => context.isAvailableFederationDirective('external', d), ); @@ -318,6 +322,7 @@ export function visitFields({ } if ( + !isTypename && interceptInterfaceType && (innerTypeDef.kind === Kind.INTERFACE_TYPE_DEFINITION || innerTypeDef.kind === Kind.INTERFACE_TYPE_EXTENSION) @@ -345,9 +350,13 @@ export function visitFields({ context, selectionSet: innerSelection, typeDefinition: innerTypeDef, + interceptField, interceptArguments, interceptUnknownField, interceptInterfaceType, + interceptDirective, + interceptExternalField, + interceptFieldWithMissingSelectionSet, }); } } diff --git a/src/subgraph/validation/rules/elements/provides.ts b/src/subgraph/validation/rules/elements/provides.ts index a16297f..efee7ea 100644 --- a/src/subgraph/validation/rules/elements/provides.ts +++ b/src/subgraph/validation/rules/elements/provides.ts @@ -262,10 +262,12 @@ export function ProvidesRules(context: SubgraphValidationContext): ASTVisitor { info.typeDefinition.kind === Kind.OBJECT_TYPE_DEFINITION || info.typeDefinition.kind === Kind.OBJECT_TYPE_EXTENSION ) { - context.stateBuilder.objectType.field.markAsProvided( - info.typeDefinition.name.value, - info.fieldName, - ); + if (info.fieldName !== '__typename') { + context.stateBuilder.objectType.field.markAsProvided( + info.typeDefinition.name.value, + info.fieldName, + ); + } } }, }); diff --git a/src/subgraph/validation/rules/elements/requires.ts b/src/subgraph/validation/rules/elements/requires.ts index 0b62fd0..397a4e6 100644 --- a/src/subgraph/validation/rules/elements/requires.ts +++ b/src/subgraph/validation/rules/elements/requires.ts @@ -125,10 +125,12 @@ export function RequiresRules(context: SubgraphValidationContext): ASTVisitor { info.typeDefinition.kind === Kind.OBJECT_TYPE_DEFINITION || info.typeDefinition.kind === Kind.OBJECT_TYPE_EXTENSION ) { - context.stateBuilder.objectType.field.markedAsRequired( - info.typeDefinition.name.value, - info.fieldName, - ); + if (info.fieldName !== '__typename') { + context.stateBuilder.objectType.field.markedAsRequired( + info.typeDefinition.name.value, + info.fieldName, + ); + } } }, interceptUnknownField(info) { diff --git a/src/subgraph/validation/validate-subgraph.ts b/src/subgraph/validation/validate-subgraph.ts index 283316d..30b49a8 100644 --- a/src/subgraph/validation/validate-subgraph.ts +++ b/src/subgraph/validation/validate-subgraph.ts @@ -96,6 +96,34 @@ export function validateSubgraph( ) { subgraph.typeDefs = cleanSubgraphTypeDefsFromSubgraphSpec(subgraph.typeDefs); + const linkSpecDefinitions = parse(/* GraphQL */ ` + enum Purpose { + EXECUTION + SECURITY + } + + directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] + ) repeatable on SCHEMA + + scalar link__Import + + enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION + } + `).definitions; + const rulesToSkip = __internal?.disableValidationRules ?? []; const typeNodeInfo = new TypeNodeInfo(); const validationContext = createSubgraphValidationContext( @@ -164,6 +192,14 @@ export function validateSubgraph( const federationDefinitionReplacements = validationContext.collectFederationDefinitionReplacements(); + // Include only link spec definitions that are not already defined in the subgraph + const linkSpecDefinitionsToInclude = linkSpecDefinitions.filter(def => { + if ('name' in def && typeof def.name?.value === 'string') { + return !stateBuilder.state.types.has(def.name.value); + } + + return true; + }); const fullTypeDefs = concatAST( [ { @@ -176,33 +212,12 @@ export function validateSubgraph( ? // TODO: If Link v1.0 spec is detected in the subgraph (`schema @link(url: ".../link/v1.0")`) // We should validate its directives and types // just like we do with Federation directives and types. - parse(/* GraphQL */ ` - enum Purpose { - EXECUTION - SECURITY - } - - directive @link( - url: String - as: String - for: link__Purpose - import: [link__Import] - ) repeatable on SCHEMA - - scalar link__Import - - enum link__Purpose { - """ - \`SECURITY\` features provide metadata necessary to securely resolve fields. - """ - SECURITY - - """ - \`EXECUTION\` features provide metadata necessary for operation execution. - """ - EXECUTION - } - `) + linkSpecDefinitionsToInclude.length > 0 + ? ({ + kind: Kind.DOCUMENT, + definitions: linkSpecDefinitionsToInclude, + } as DocumentNode) + : null : null, subgraph.typeDefs, ].filter(onlyDocumentNode), diff --git a/src/supergraph/composition/ast.ts b/src/supergraph/composition/ast.ts index 2642d09..457aa5e 100644 --- a/src/supergraph/composition/ast.ts +++ b/src/supergraph/composition/ast.ts @@ -33,6 +33,7 @@ import { visit, visitInParallel, } from 'graphql'; +import { print } from '../../graphql/printer.js'; type inferArgument = T extends (arg: infer A) => any ? A : never; @@ -1039,8 +1040,18 @@ function applyDirectives(common: { policies?: string[][]; scopes?: string[][]; }) { + const deduplicatedDirectives = (common.ast?.directives ?? []) + .map(directive => { + return { + ast: directive, + string: print(directive), + }; + }) + .filter((directive, index, all) => all.findIndex(d => d.string === directive.string) === index) + .map(d => d.ast); + return ([] as ConstDirectiveNode[]).concat( - common.ast?.directives ?? [], + deduplicatedDirectives, common.join?.type?.map(createJoinTypeDirectiveNode) ?? [], common.join?.implements?.map(createJoinImplementsDirectiveNode) ?? [], common.join?.field?.map(createJoinFieldDirectiveNode) ?? [], diff --git a/src/supergraph/composition/object-type.ts b/src/supergraph/composition/object-type.ts index c636724..61909a4 100644 --- a/src/supergraph/composition/object-type.ts +++ b/src/supergraph/composition/object-type.ts @@ -377,124 +377,100 @@ export function objectTypeBuilder(): TypeBuilder { directives: convertToConst(objectType.ast.directives), }, description: objectType.description, - fields: Array.from(objectType.fields.values()).map(field => { - const fieldInGraphs = Array.from(field.byGraph.entries()); + fields: Array.from(objectType.fields.values()) + .map(field => { + const fieldInGraphs = Array.from(field.byGraph.entries()); - const hasDifferentOutputType = fieldInGraphs.some( - ([_, meta]) => meta.type !== field.type, - ); - const isDefinedEverywhere = - field.byGraph.size === (isQuery ? graphs.size : objectType.byGraph.size); - let joinFields: JoinFieldAST[] = []; - - const overridesMap: { - [originalGraphId: string]: string; - } = {}; - - const differencesBetweenGraphs = { - override: false, - type: false, - external: false, - provides: false, - requires: false, - }; + const hasDifferentOutputType = fieldInGraphs.some( + ([_, meta]) => meta.type !== field.type, + ); + const isDefinedEverywhere = + field.byGraph.size === (isQuery ? graphs.size : objectType.byGraph.size); + let joinFields: JoinFieldAST[] = []; + + const overridesMap: { + [originalGraphId: string]: string; + } = {}; + + const differencesBetweenGraphs = { + override: false, + type: false, + external: false, + provides: false, + requires: false, + }; - for (const [graphId, meta] of fieldInGraphs) { - if (meta.external) { - differencesBetweenGraphs.external = field.usedAsKey - ? objectType.byGraph.get(graphId)!.extension !== true - : true; - } - if (meta.override !== null) { - differencesBetweenGraphs.override = true; + for (const [graphId, meta] of fieldInGraphs) { + if (meta.external) { + differencesBetweenGraphs.external = field.usedAsKey + ? objectType.byGraph.get(graphId)!.extension !== true + : true; + } + if (meta.override !== null) { + differencesBetweenGraphs.override = true; - const originalGraphId = graphNameToId(meta.override); - if (originalGraphId) { - overridesMap[originalGraphId] = graphId; + const originalGraphId = graphNameToId(meta.override); + if (originalGraphId) { + overridesMap[originalGraphId] = graphId; + } + } + if (meta.provides !== null) { + differencesBetweenGraphs.provides = true; + } + if (meta.requires !== null) { + differencesBetweenGraphs.requires = true; + } + if (meta.type !== field.type) { + differencesBetweenGraphs.type = true; } } - if (meta.provides !== null) { - differencesBetweenGraphs.provides = true; - } - if (meta.requires !== null) { - differencesBetweenGraphs.requires = true; - } - if (meta.type !== field.type) { - differencesBetweenGraphs.type = true; - } - } - if (isQuery) { - // If it's a Query type, we don't need to emit `@join__field` directives when there's only one graph - // We do not have to emit `@join__field` if the field is shareable in every graph as well. - - if (differencesBetweenGraphs.override) { - const graphsWithOverride = fieldInGraphs.filter( - ([_, meta]) => meta.override !== null, - ); + if (!isQuery && field.byGraph.size === 1) { + const graphId = field.byGraph.keys().next().value; + const fieldInGraph = field.byGraph.get(graphId)!; - joinFields = graphsWithOverride.map(([graphId, meta]) => ({ - graph: graphId, - override: meta.override ?? undefined, - usedOverridden: provideUsedOverriddenValue( + if ( + // a field is external + fieldInGraph.external && + // it's not used as a key + !fieldInGraph.usedAsKey && + // it's not part of any @requires(fields:) + !fieldInGraph.required && + // it's not part of any @provides(fields:) + !fieldInGraph.provided && + // it's not part of any @override(from:) and it's not used by any interface + !provideUsedOverriddenValue( field.name, overridesMap, fieldNamesOfImplementedInterfaces, graphId, - ), - type: differencesBetweenGraphs.type ? meta.type : undefined, - external: meta.external ?? undefined, - provides: meta.provides ?? undefined, - requires: meta.requires ?? undefined, - })); - } else { - joinFields = - graphs.size > 1 && !isDefinedEverywhere - ? fieldInGraphs.map(([graphId, meta]) => ({ - graph: graphId, - provides: differencesBetweenGraphs.provides - ? meta.provides ?? undefined - : undefined, - })) - : []; + ) && + // and it's Federation v1 + graphs.get(graphId)!.version === 'v1.0' + ) { + // drop the field + return null; + } } - } else if (isDefinedEverywhere) { - const hasDifferencesBetweenGraphs = Object.values(differencesBetweenGraphs).some( - value => value === true, - ); - // We probably need to emit `@join__field` for every graph, except the one where the override was applied - if (differencesBetweenGraphs.override) { - const overriddenGraphs = fieldInGraphs - .map(([_, meta]) => (meta.override ? graphNameToId(meta.override) : null)) - .filter((graphId): graphId is string => typeof graphId === 'string'); - - // the exception is when a field is external, we need to emit `@join__field` for that graph, - // so gateway knows that it's an external field - const graphsToEmit = fieldInGraphs.filter(([graphId, f]) => { - const isExternal = f.external === true; - const isOverridden = overriddenGraphs.includes(graphId); - const needsToPrintUsedOverridden = provideUsedOverriddenValue( - field.name, - overridesMap, - fieldNamesOfImplementedInterfaces, - graphId, + if (isQuery) { + // If it's a Query type, we don't need to emit `@join__field` directives when there's only one graph + // We do not have to emit `@join__field` if the field is shareable in every graph as well. + + if (differencesBetweenGraphs.override) { + const graphsWithOverride = fieldInGraphs.filter( + ([_, meta]) => + meta.override !== null && + (objectType.byGraph.size > 1 + ? // if there's more than one graph + // we want to emit `@join__field` with override even when it's pointing to a non-existing subgraph + true + : // but if there's only one graph, + // we don't want to emit `@join__field` if the override is pointing to a non-existing subgraph + typeof graphNameToId(meta.override) === 'string'), ); - const isRequired = f.required === true; - return (isExternal && isRequired) || needsToPrintUsedOverridden || !isOverridden; - }); - - // Do not emit `@join__field` if there's only one graph left - // and the type has a single `@join__type` matching the graph. - if ( - !( - graphsToEmit.length === 1 && - joinTypes.length === 1 && - joinTypes[0].graph === graphsToEmit[0][0] - ) - ) { - joinFields = graphsToEmit.map(([graphId, meta]) => ({ + joinFields = graphsWithOverride.map(([graphId, meta]) => ({ graph: graphId, override: meta.override ?? undefined, usedOverridden: provideUsedOverriddenValue( @@ -508,109 +484,171 @@ export function objectTypeBuilder(): TypeBuilder { provides: meta.provides ?? undefined, requires: meta.requires ?? undefined, })); + } else { + joinFields = + graphs.size > 1 && !isDefinedEverywhere + ? fieldInGraphs.map(([graphId, meta]) => ({ + graph: graphId, + provides: differencesBetweenGraphs.provides + ? meta.provides ?? undefined + : undefined, + })) + : []; } - } else if (hasDifferencesBetweenGraphs) { - joinFields = createJoinFields(fieldInGraphs, field, { - hasDifferentOutputType, - overridesMap, - }); - } - } else { - // An override is a special case, we need to emit `@join__field` only for graphs where @override was applied - if (differencesBetweenGraphs.override) { - const overriddenGraphs = fieldInGraphs - .map(([_, meta]) => (meta.override ? graphNameToId(meta.override) : null)) - .filter((graphId): graphId is string => typeof graphId === 'string'); - - const graphsToPrintJoinField = fieldInGraphs.filter( - ([graphId, meta]) => - meta.override !== null || - // we want to print `external: true` as it's still needed by the query planner - meta.external === true || - (meta.shareable && !overriddenGraphs.includes(graphId)) || - provideUsedOverriddenValue( + } else if (isDefinedEverywhere) { + const hasDifferencesBetweenGraphs = Object.values(differencesBetweenGraphs).some( + value => value === true, + ); + + // We probably need to emit `@join__field` for every graph, except the one where the override was applied + if (differencesBetweenGraphs.override) { + const overriddenGraphs = fieldInGraphs + .map(([_, meta]) => (meta.override ? graphNameToId(meta.override) : null)) + .filter((graphId): graphId is string => typeof graphId === 'string'); + + // the exception is when a field is external, we need to emit `@join__field` for that graph, + // so gateway knows that it's an external field + const graphsToEmit = fieldInGraphs.filter(([graphId, f]) => { + const isExternal = f.external === true; + const isOverridden = overriddenGraphs.includes(graphId); + const needsToPrintUsedOverridden = provideUsedOverriddenValue( field.name, overridesMap, fieldNamesOfImplementedInterfaces, graphId, - ), - ); - - joinFields = graphsToPrintJoinField.map(([graphId, meta]) => ({ - graph: graphId, - override: meta.override ?? undefined, - usedOverridden: provideUsedOverriddenValue( - field.name, + ); + const isRequired = f.required === true; + + return (isExternal && isRequired) || needsToPrintUsedOverridden || !isOverridden; + }); + + // Do not emit `@join__field` if there's only one graph left + // and the type has a single `@join__type` matching the graph. + if ( + !( + graphsToEmit.length === 1 && + joinTypes.length === 1 && + joinTypes[0].graph === graphsToEmit[0][0] + ) + ) { + joinFields = graphsToEmit.map(([graphId, meta]) => ({ + graph: graphId, + override: meta.override ?? undefined, + usedOverridden: provideUsedOverriddenValue( + field.name, + overridesMap, + fieldNamesOfImplementedInterfaces, + graphId, + ), + type: differencesBetweenGraphs.type ? meta.type : undefined, + external: meta.external ?? undefined, + provides: meta.provides ?? undefined, + requires: meta.requires ?? undefined, + })); + } + } else if (hasDifferencesBetweenGraphs) { + joinFields = createJoinFields(fieldInGraphs, field, { + hasDifferentOutputType, overridesMap, - fieldNamesOfImplementedInterfaces, - graphId, - ), - type: differencesBetweenGraphs.type ? meta.type : undefined, - external: meta.external ?? undefined, - provides: meta.provides ?? undefined, - requires: meta.requires ?? undefined, - })); + }); + } } else { - joinFields = createJoinFields(fieldInGraphs, field, { - hasDifferentOutputType, - overridesMap, - }); - } - } + // An override is a special case, we need to emit `@join__field` only for graphs where @override was applied + if (differencesBetweenGraphs.override) { + const overriddenGraphs = fieldInGraphs + .map(([_, meta]) => (meta.override ? graphNameToId(meta.override) : null)) + .filter((graphId): graphId is string => typeof graphId === 'string'); + + const graphsToPrintJoinField = fieldInGraphs.filter( + ([graphId, meta]) => + meta.override !== null || + // we want to print `external: true` as it's still needed by the query planner + meta.external === true || + (meta.shareable && !overriddenGraphs.includes(graphId)) || + provideUsedOverriddenValue( + field.name, + overridesMap, + fieldNamesOfImplementedInterfaces, + graphId, + ), + ); - return { - name: field.name, - type: field.type, - inaccessible: field.inaccessible, - authenticated: field.authenticated, - policies: field.policies, - scopes: field.scopes, - tags: Array.from(field.tags), - description: field.description, - deprecated: field.deprecated, - ast: { - directives: convertToConst(field.ast.directives), - }, - join: { - field: - // If there's only one graph on both field and type - // and it has no properties, we don't need to emit `@join__field` - joinFields.length === 1 && - joinTypes.length === 1 && - !joinFields[0].external && - !joinFields[0].override && - !joinFields[0].provides && - !joinFields[0].requires && - !joinFields[0].usedOverridden && - !joinFields[0].type - ? [] - : joinFields, - }, - arguments: Array.from(field.args.values()) - .filter(arg => { - // ignore the argument if it's not available in all subgraphs implementing the field - if (arg.byGraph.size !== field.byGraph.size) { - return false; - } + joinFields = graphsToPrintJoinField.map(([graphId, meta]) => ({ + graph: graphId, + override: meta.override ?? undefined, + usedOverridden: provideUsedOverriddenValue( + field.name, + overridesMap, + fieldNamesOfImplementedInterfaces, + graphId, + ), + type: differencesBetweenGraphs.type ? meta.type : undefined, + external: meta.external ?? undefined, + provides: meta.provides ?? undefined, + requires: meta.requires ?? undefined, + })); + } else { + joinFields = createJoinFields(fieldInGraphs, field, { + hasDifferentOutputType, + overridesMap, + }); + } + } - return true; - }) - .map(arg => { - return { - name: arg.name, - type: arg.type, - inaccessible: arg.inaccessible, - tags: Array.from(arg.tags), - defaultValue: arg.defaultValue, - description: arg.description, - deprecated: arg.deprecated, - ast: { - directives: convertToConst(arg.ast.directives), - }, - }; - }), - }; - }), + return { + name: field.name, + type: field.type, + inaccessible: field.inaccessible, + authenticated: field.authenticated, + policies: field.policies, + scopes: field.scopes, + tags: Array.from(field.tags), + description: field.description, + deprecated: field.deprecated, + ast: { + directives: convertToConst(field.ast.directives), + }, + join: { + field: + // If there's only one graph on both field and type + // and it has no properties, we don't need to emit `@join__field` + joinFields.length === 1 && + joinTypes.length === 1 && + !joinFields[0].external && + !joinFields[0].override && + !joinFields[0].provides && + !joinFields[0].requires && + !joinFields[0].usedOverridden && + !joinFields[0].type + ? [] + : joinFields, + }, + arguments: Array.from(field.args.values()) + .filter(arg => { + // ignore the argument if it's not available in all subgraphs implementing the field + if (arg.byGraph.size !== field.byGraph.size) { + return false; + } + + return true; + }) + .map(arg => { + return { + name: arg.name, + type: arg.type, + inaccessible: arg.inaccessible, + tags: Array.from(arg.tags), + defaultValue: arg.defaultValue, + description: arg.description, + deprecated: arg.deprecated, + ast: { + directives: convertToConst(arg.ast.directives), + }, + }; + }), + }; + }) + .filter(isDefined), interfaces: Array.from(objectType.interfaces), tags: Array.from(objectType.tags), inaccessible: objectType.inaccessible,