From 80ea57b537b1fa28824bc96450af5530a6960cf9 Mon Sep 17 00:00:00 2001 From: dariuszkuc <9501705+dariuszkuc@users.noreply.github.com> Date: Thu, 23 Oct 2025 19:58:27 -0500 Subject: [PATCH 1/2] fix: handle @requires dependency on fields returned by @interfaceObject Depending on the merge order of the types, we could fail composition if a type that `@requires` data from an `@interfaceObject` is merged before the interface. Updated merge logic to use explicit merge order of scalars, input objects, input objects and finally objects. --- .changeset/ten-years-flash.md | 7 + composition-js/src/__tests__/compose.test.ts | 139 +++++++++++++++++++ composition-js/src/merging/merge.ts | 18 ++- 3 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 .changeset/ten-years-flash.md diff --git a/.changeset/ten-years-flash.md b/.changeset/ten-years-flash.md new file mode 100644 index 000000000..ebc399a56 --- /dev/null +++ b/.changeset/ten-years-flash.md @@ -0,0 +1,7 @@ +--- +"@apollo/composition": patch +--- + +Fixed handling `@requires` dependency on fields returned by `@interfaceObject` + +Depending on the merge order of the types, we could fail composition if a type that `@requires` data from an `@interfaceObject` is merged before the interface. Updated merge logic to use explicit merge order of scalars, input objects, input objects and finally objects. diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index 3cc57c12f..53b96f120 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -4075,6 +4075,145 @@ describe('composition', () => { const result = composeAsFed2Subgraphs([subgraphA, subgraphB]); assertCompositionSuccess(result); }); + + it('composes @requires references to @interfaceObject', () => { + const subgraph1 = { + name: 'A', + url: 'https://Subgraph1', + typeDefs: gql` + + type T implements I @key(fields: "id") { + id: ID! + i1: U! @external + specific: U! @requires(fields: "i1 { u1 }") + } + + interface I @key(fields: "id") { + id: ID! + i1: U! + } + + type U @shareable { + u1: String + } + + type Query { + example: T! + } + ` + } + + const subgraph2 = { + name: 'B', + url: 'https://Subgraph2', + typeDefs: gql` + type I @key(fields: "id") @interfaceObject { + id: ID! + i1: U! + } + + type U @shareable { + u1: String + } + ` + } + + let result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + console.log(result.supergraphSdl); + + expect(result.supergraphSdl).toMatchString(` + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + { + query: Query + } + + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + interface I + @join__type(graph: A, key: "id") + @join__type(graph: B, key: "id", isInterfaceObject: true) + { + id: ID! + i1: U! + } + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + scalar join__DirectiveArguments + + scalar join__FieldSet + + scalar join__FieldValue + + enum join__Graph { + A @join__graph(name: "A", url: "https://Subgraph1") + B @join__graph(name: "B", url: "https://Subgraph2") + } + + 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 + } + + type Query + @join__type(graph: A) + @join__type(graph: B) + { + example: T! @join__field(graph: A) + } + + type T implements I + @join__implements(graph: A, interface: "I") + @join__type(graph: A, key: "id") + { + id: ID! + i1: U! @join__field(graph: A, external: true) + specific: U! @join__field(graph: A, requires: "i1 { u1 }") + } + + type U + @join__type(graph: A) + @join__type(graph: B) + { + u1: String + } + `); + + // composes regardless of the subgraph order + result = composeAsFed2Subgraphs([subgraph2, subgraph1]); + assertCompositionSuccess(result); + }) }); describe('@authenticated', () => { diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 6548b45a2..0b41686fd 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -85,6 +85,7 @@ import { inaccessibleIdentity, FeatureDefinitions, CONNECT_VERSIONS, + ScalarType } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -654,7 +655,8 @@ class Merger { const interfaceTypes: InterfaceType[] = []; const unionTypes: UnionType[] = []; const enumTypes: EnumType[] = []; - const nonUnionEnumTypes: NamedType[] = []; + const scalarTypes: ScalarType[] = []; + const inputObjectTypes: InputObjectType[] = []; this.merged.types().forEach(type => { if ( @@ -667,19 +669,23 @@ class Merger { switch (type.kind) { case 'UnionType': unionTypes.push(type); - return; + break; case 'EnumType': enumTypes.push(type); - return; + break; case 'ObjectType': objectTypes.push(type); break; case 'InterfaceType': interfaceTypes.push(type); break; + case 'ScalarType': + scalarTypes.push(type); + break; + case 'InputObjectType': + inputObjectTypes.push(type); + break; } - - nonUnionEnumTypes.push(type); }); // Then, for object and interface types, we merge the 'implements' relationship, and we merge the unions. @@ -705,7 +711,7 @@ class Merger { ); // We've already merged unions above and we've going to merge enums last - for (const type of nonUnionEnumTypes) { + for (const type of [...scalarTypes, ...inputObjectTypes, ...interfaceTypes, ...objectTypes]) { this.mergeType(this.subgraphsTypes(type), type); } From fd952a7ee6acbfb4aba5887025c825b72e54f34d Mon Sep 17 00:00:00 2001 From: Dariusz Kuc <9501705+dariuszkuc@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:30:40 -0500 Subject: [PATCH 2/2] Update .changeset/ten-years-flash.md Co-authored-by: Sachin D. Shinde --- .changeset/ten-years-flash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ten-years-flash.md b/.changeset/ten-years-flash.md index ebc399a56..62198ac5d 100644 --- a/.changeset/ten-years-flash.md +++ b/.changeset/ten-years-flash.md @@ -4,4 +4,4 @@ Fixed handling `@requires` dependency on fields returned by `@interfaceObject` -Depending on the merge order of the types, we could fail composition if a type that `@requires` data from an `@interfaceObject` is merged before the interface. Updated merge logic to use explicit merge order of scalars, input objects, input objects and finally objects. +Depending on the merge order of the types, we could fail composition if a type that `@requires` data from an `@interfaceObject` is merged before the interface. Updated merge logic to use explicit merge order of scalars, input objects, interfaces, and finally objects.