From 965db3174b052995c23d4d5839d7b60907aeeeb5 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:54:13 -0700 Subject: [PATCH 1/4] fix: tag extract respects @external on parent --- .../federation-tag-extraction.spec.ts | 82 +++++++++++++++++++ src/contracts/tag-extraction.ts | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/__tests__/contracts/federation-tag-extraction.spec.ts b/__tests__/contracts/federation-tag-extraction.spec.ts index 5ad2102..6960174 100644 --- a/__tests__/contracts/federation-tag-extraction.spec.ts +++ b/__tests__/contracts/federation-tag-extraction.spec.ts @@ -395,6 +395,88 @@ describe("applyTagFilterToInaccessibleTransformOnSubgraphSchema", () => { `); }); + test("include of object type field if @external is applied to field", () => { + const filter: SchemaContractFilter = { + include: new Set(["tag1"]), + exclude: new Set(), + }; + const sdl = parse(/* GraphQL */ ` + schema + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@tag", "@external"] + ) { + query: Query + } + + type Query { + field1: String! @tag(name: "tag1") @requires(fields: "field2") + field2: String! @external + } + `); + + const output = applyTagFilterToInaccessibleTransformOnSubgraphSchema( + sdl, + buildSchemaCoordinateTagRegister([sdl]), + filter, + ); + + expect(print(output.typeDefs)).toMatchInlineSnapshot(` + "schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag", "@external"]) { + query: Query + } + + type Query { + field1: String! @requires(fields: "field2") + field2: String! @external + }" + `); + }); + + test("include of object type field if @external is applied to object", () => { + const filter: SchemaContractFilter = { + include: new Set(["tag1"]), + exclude: new Set(), + }; + const sdl = parse(/* GraphQL */ ` + schema + @link( + url: "https://specs.apollo.dev/federation/v2.0" + import: ["@tag", "@external"] + ) { + query: Query + } + + extend type Query @external { + field2: String! + } + + type Query { + field1: String! @tag(name: "tag1") @requires(fields: "field2") + } + `); + + const output = applyTagFilterToInaccessibleTransformOnSubgraphSchema( + sdl, + buildSchemaCoordinateTagRegister([sdl]), + filter, + ); + + expect(print(output.typeDefs)).toMatchInlineSnapshot(` + "schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag", "@external"]) { + query: Query + } + + extend type Query @external { + field2: String! + } + + type Query { + field1: String! @requires(fields: "field2") + }" + `); + }); + test("object type is excluded even if one of its fields is included", () => { const filter: SchemaContractFilter = { include: new Set(["tag1"]), diff --git a/src/contracts/tag-extraction.ts b/src/contracts/tag-extraction.ts index ebb4acc..228c085 100644 --- a/src/contracts/tag-extraction.ts +++ b/src/contracts/tag-extraction.ts @@ -458,7 +458,7 @@ export function applyTagFilterToInaccessibleTransformOnSubgraphSchema( if ( fieldNode.directives?.find( (d) => d.name.value === externalDirectiveName, - ) + ) || node.directives?.find(d => d.name.value === externalDirectiveName) ) { return fieldNode; } From 00ae7eae7bd360d9f11d06804cbf52a4b629aa80 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:56:07 -0700 Subject: [PATCH 2/4] Changeset --- .changeset/dirty-humans-prove.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dirty-humans-prove.md diff --git a/.changeset/dirty-humans-prove.md b/.changeset/dirty-humans-prove.md new file mode 100644 index 0000000..8e3fdbd --- /dev/null +++ b/.changeset/dirty-humans-prove.md @@ -0,0 +1,5 @@ +--- +"@theguild/federation-composition": patch +--- + +tag extraction respects @external on parent From 6a3b9f93de18558611f3e6872d639ef744e66d8f Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:57:42 -0700 Subject: [PATCH 3/4] format --- __tests__/composition.spec.ts | 38 ++++++++++++++++----------------- src/contracts/tag-extraction.ts | 3 ++- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/__tests__/composition.spec.ts b/__tests__/composition.spec.ts index cafab7c..f2d056a 100644 --- a/__tests__/composition.spec.ts +++ b/__tests__/composition.spec.ts @@ -7382,8 +7382,8 @@ testImplementations((api) => { typeDefs: parse(/* GraphQL */ ` extend schema @link( - url: "https://specs.apollo.dev/federation/v2.3" - import: ["@key", "@shareable"] + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] ) type Note @key(fields: "id") @shareable { id: ID! @@ -7391,16 +7391,16 @@ testImplementations((api) => { author: User } type User @key(fields: "id") { - id: ID! - name: String + id: ID! + name: String } - type PrivateNote @key(fields: "id") @shareable { - id: ID! - note: Note - } - type Query { - note: Note @shareable - privateNote: PrivateNote @shareable + type PrivateNote @key(fields: "id") @shareable { + id: ID! + note: Note + } + type Query { + note: Note @shareable + privateNote: PrivateNote @shareable } `), }, @@ -7425,9 +7425,9 @@ testImplementations((api) => { result = api.composeServices([ { name: "foo", - typeDefs: parse(/* GraphQL */ ` + typeDefs: parse(/* GraphQL */ ` extend schema - @link( + @link( url: "https://specs.apollo.dev/federation/v2.3" import: ["@key", "@external", "@provides", "@shareable"] ) @@ -7442,8 +7442,8 @@ testImplementations((api) => { type PrivateNote @key(fields: "id") @shareable { id: ID! note: Note @provides(fields: "name author { id }") - } - type Query { + } + type Query { note: Note @shareable privateNote: PrivateNote @shareable } @@ -7461,8 +7461,8 @@ testImplementations((api) => { id: ID! name: String author: User - } - type User @key(fields: "id") { + } + type User @key(fields: "id") { id: ID! name: String } @@ -7490,7 +7490,7 @@ testImplementations((api) => { type Note @key(fields: "id") @shareable { id: ID! tag: String - } + } `), }, ]); @@ -7510,7 +7510,7 @@ testImplementations((api) => { @join__field(external: true, graph: FOO) @join__field(graph: BAR) tag: String @join__field(graph: BAZ) - } + } `); }); diff --git a/src/contracts/tag-extraction.ts b/src/contracts/tag-extraction.ts index 228c085..14a2df7 100644 --- a/src/contracts/tag-extraction.ts +++ b/src/contracts/tag-extraction.ts @@ -458,7 +458,8 @@ export function applyTagFilterToInaccessibleTransformOnSubgraphSchema( if ( fieldNode.directives?.find( (d) => d.name.value === externalDirectiveName, - ) || node.directives?.find(d => d.name.value === externalDirectiveName) + ) || + node.directives?.find((d) => d.name.value === externalDirectiveName) ) { return fieldNode; } From 3761e2c631bf020133e9f060b778660bfff10ca9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 21:17:07 -0700 Subject: [PATCH 4/4] add support for graphql v14 and v15 --- package.json | 2 +- src/subgraph/state.ts | 8 ++++---- src/subgraph/validation/rules/known-directives-rule.ts | 8 ++++---- .../validation/rules/query-root-type-inaccessible-rule.ts | 6 +++--- src/subgraph/validation/rules/root-type-used-rule.ts | 8 ++++---- src/subgraph/validation/validate-subgraph.ts | 5 ++--- src/supergraph/composition/ast.ts | 8 ++++---- src/supergraph/validation/rules/satisfiablity-rule.ts | 1 - .../validation/rules/satisfiablity/supergraph.ts | 2 +- src/supergraph/validation/rules/satisfiablity/walker.ts | 6 +++--- 10 files changed, 26 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index afa711f..afe36a3 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "typecheck": "tsc --noEmit --project tsconfig.build.json" }, "peerDependencies": { - "graphql": "^16.0.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "dependencies": { "constant-case": "^3.0.4", diff --git a/src/subgraph/state.ts b/src/subgraph/state.ts index a75ed20..2b8ab61 100644 --- a/src/subgraph/state.ts +++ b/src/subgraph/state.ts @@ -8,7 +8,7 @@ import { Kind, ObjectTypeDefinitionNode, ObjectTypeExtensionNode, - OperationTypeNode, + type OperationTypeNode, SchemaDefinitionNode, specifiedDirectives as specifiedDirectiveTypes, specifiedScalarTypes, @@ -398,17 +398,17 @@ export function createSubgraphStateBuilder( const expectedQueryTypeName = decideOnRootTypeName( schemaDef, - OperationTypeNode.QUERY, + 'query' as OperationTypeNode, "Query", ); const expectedMutationTypeName = decideOnRootTypeName( schemaDef, - OperationTypeNode.MUTATION, + 'mutation' as OperationTypeNode, "Mutation", ); const expectedSubscriptionTypeName = decideOnRootTypeName( schemaDef, - OperationTypeNode.SUBSCRIPTION, + 'subscription' as OperationTypeNode, "Subscription", ); diff --git a/src/subgraph/validation/rules/known-directives-rule.ts b/src/subgraph/validation/rules/known-directives-rule.ts index 33fafe2..2624ee1 100644 --- a/src/subgraph/validation/rules/known-directives-rule.ts +++ b/src/subgraph/validation/rules/known-directives-rule.ts @@ -5,7 +5,7 @@ import { DocumentNode, GraphQLError, Kind, - OperationTypeNode, + type OperationTypeNode, specifiedDirectives, } from "graphql"; @@ -135,11 +135,11 @@ function getDirectiveLocationForOperation( operation: OperationTypeNode, ): DirectiveLocation { switch (operation) { - case OperationTypeNode.QUERY: + case 'query' as OperationTypeNode.QUERY: return DirectiveLocation.QUERY; - case OperationTypeNode.MUTATION: + case 'mutation' as OperationTypeNode.MUTATION: return DirectiveLocation.MUTATION; - case OperationTypeNode.SUBSCRIPTION: + case 'subscription' as OperationTypeNode.SUBSCRIPTION: return DirectiveLocation.SUBSCRIPTION; } } diff --git a/src/subgraph/validation/rules/query-root-type-inaccessible-rule.ts b/src/subgraph/validation/rules/query-root-type-inaccessible-rule.ts index 86154e3..56f0a42 100644 --- a/src/subgraph/validation/rules/query-root-type-inaccessible-rule.ts +++ b/src/subgraph/validation/rules/query-root-type-inaccessible-rule.ts @@ -1,4 +1,4 @@ -import { ASTVisitor, GraphQLError, OperationTypeNode } from "graphql"; +import { ASTVisitor, GraphQLError, type OperationTypeNode } from "graphql"; import type { SubgraphValidationContext } from "../validation-context.js"; export function QueryRootTypeInaccessibleRule( @@ -10,7 +10,7 @@ export function QueryRootTypeInaccessibleRule( SchemaDefinition(node) { const nonQueryType = node.operationTypes?.find( (operationType) => - operationType.operation === OperationTypeNode.QUERY && + operationType.operation === 'query' && operationType.type.name.value !== "Query", ); @@ -21,7 +21,7 @@ export function QueryRootTypeInaccessibleRule( SchemaExtension(node) { const nonQueryType = node.operationTypes?.find( (operationType) => - operationType.operation === OperationTypeNode.QUERY && + operationType.operation === 'query' && operationType.type.name.value !== "Query", ); diff --git a/src/subgraph/validation/rules/root-type-used-rule.ts b/src/subgraph/validation/rules/root-type-used-rule.ts index 0e71390..825a301 100644 --- a/src/subgraph/validation/rules/root-type-used-rule.ts +++ b/src/subgraph/validation/rules/root-type-used-rule.ts @@ -3,7 +3,7 @@ import { DefinitionNode, GraphQLError, isTypeDefinitionNode, - OperationTypeNode, + type OperationTypeNode, SchemaDefinitionNode, SchemaExtensionNode, } from "graphql"; @@ -26,13 +26,13 @@ function findDefaultRootTypes(definitions: readonly DefinitionNode[]) { if (isTypeDefinitionNode(definition)) { if (definition.name.value === "Query") { foundRootTypes.query = "Query"; - found.add(OperationTypeNode.QUERY); + found.add('query' as OperationTypeNode); } else if (definition.name.value === "Mutation") { foundRootTypes.mutation = "Mutation"; - found.add(OperationTypeNode.MUTATION); + found.add('mutation' as OperationTypeNode); } else if (definition.name.value === "Subscription") { foundRootTypes.subscription = "Subscription"; - found.add(OperationTypeNode.SUBSCRIPTION); + found.add('subscription' as OperationTypeNode); } } } diff --git a/src/subgraph/validation/validate-subgraph.ts b/src/subgraph/validation/validate-subgraph.ts index 4abcc30..9ab6503 100644 --- a/src/subgraph/validation/validate-subgraph.ts +++ b/src/subgraph/validation/validate-subgraph.ts @@ -8,7 +8,6 @@ import { Kind, ObjectTypeDefinitionNode, ObjectTypeExtensionNode, - OperationTypeNode, parse, SchemaDefinitionNode, SchemaExtensionNode, @@ -541,13 +540,13 @@ function cleanSubgraphTypeDefsFromSubgraphSpec(typeDefs: DocumentNode) { (node.kind === Kind.SCHEMA_DEFINITION || node.kind === Kind.SCHEMA_EXTENSION) && node.operationTypes?.some( - (op) => op.operation === OperationTypeNode.QUERY, + (op) => op.operation === 'query', ), ) as SchemaDefinitionNode | SchemaExtensionNode | undefined; const queryTypeName = schemaDef?.operationTypes?.find( - (op) => op.operation === OperationTypeNode.QUERY, + (op) => op.operation === 'query', )?.type.name.value ?? "Query"; (typeDefs.definitions as unknown as DefinitionNode[]) = diff --git a/src/supergraph/composition/ast.ts b/src/supergraph/composition/ast.ts index a8deef8..d2e90f7 100644 --- a/src/supergraph/composition/ast.ts +++ b/src/supergraph/composition/ast.ts @@ -21,7 +21,7 @@ import { NamedTypeNode, ObjectTypeDefinitionNode, OperationTypeDefinitionNode, - OperationTypeNode, + type OperationTypeNode, parseConstValue, parseType, ScalarTypeDefinitionNode, @@ -77,21 +77,21 @@ export function createSchemaNode(schema: { schema.query ? { kind: Kind.OPERATION_TYPE_DEFINITION, - operation: OperationTypeNode.QUERY, + operation: 'query' as OperationTypeNode, type: createNamedTypeNode(schema.query), } : [], schema.mutation ? { kind: Kind.OPERATION_TYPE_DEFINITION, - operation: OperationTypeNode.MUTATION, + operation: 'mutation' as OperationTypeNode, type: createNamedTypeNode(schema.mutation), } : [], schema.subscription ? { kind: Kind.OPERATION_TYPE_DEFINITION, - operation: OperationTypeNode.SUBSCRIPTION, + operation: 'subscription' as OperationTypeNode, type: createNamedTypeNode(schema.subscription), } : [], diff --git a/src/supergraph/validation/rules/satisfiablity-rule.ts b/src/supergraph/validation/rules/satisfiablity-rule.ts index e3dd016..894815b 100644 --- a/src/supergraph/validation/rules/satisfiablity-rule.ts +++ b/src/supergraph/validation/rules/satisfiablity-rule.ts @@ -2,7 +2,6 @@ import { GraphQLError, Kind, ListValueNode, - OperationTypeNode, print, specifiedScalarTypes, ValueNode, diff --git a/src/supergraph/validation/rules/satisfiablity/supergraph.ts b/src/supergraph/validation/rules/satisfiablity/supergraph.ts index 6253406..3d8a1fe 100644 --- a/src/supergraph/validation/rules/satisfiablity/supergraph.ts +++ b/src/supergraph/validation/rules/satisfiablity/supergraph.ts @@ -1,4 +1,4 @@ -import { OperationTypeNode } from "graphql"; +import type { OperationTypeNode } from "graphql"; import { Logger, LoggerContext } from "../../../../utils/logger.js"; import type { SupergraphState } from "../../../state.js"; import { MERGEDGRAPH_ID, SUPERGRAPH_ID } from "./constants.js"; diff --git a/src/supergraph/validation/rules/satisfiablity/walker.ts b/src/supergraph/validation/rules/satisfiablity/walker.ts index 686bcfc..9afb05f 100644 --- a/src/supergraph/validation/rules/satisfiablity/walker.ts +++ b/src/supergraph/validation/rules/satisfiablity/walker.ts @@ -1,4 +1,4 @@ -import { OperationTypeNode } from "graphql"; +import type { OperationTypeNode } from "graphql"; import type { Logger } from "../../../../utils/logger.js"; import { isAbstractEdge, isFieldEdge, type Edge } from "./edge.js"; import { LazyErrors, SatisfiabilityError } from "./errors.js"; @@ -119,9 +119,9 @@ export class Walker { } const rootNode = this.supergraph.nodeOf( - operationType === OperationTypeNode.QUERY + operationType === 'query' ? "Query" - : operationType === OperationTypeNode.MUTATION + : operationType === 'mutation' ? "Mutation" : "Subscription", false,