From 507acbd98c59e0a5042e2a2ec3745d6e02bf388a Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 14 Aug 2024 08:15:48 -0700 Subject: [PATCH 1/3] Support OBJECT and SCALAR locations for @cost directive (#3106) As a follow-up to https://github.com/apollographql/federation/pull/3074, properly support specifying `@cost` on `SCALAR` and `OBJECT` locations. --- .../__tests__/compose.demandControl.test.ts | 172 ++++++++++++------ .../src/__tests__/schemaUpgrader.test.ts | 38 ++-- .../src/extractSubgraphsFromSupergraph.ts | 7 +- 3 files changed, 151 insertions(+), 66 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 31a11fc26..62a5a4ebe 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -6,6 +6,7 @@ import { FieldDefinition, InputObjectType, ObjectType, + ScalarType, ServiceDefinition, Supergraph } from '@apollo/federation-internals'; @@ -27,11 +28,19 @@ const subgraphWithCost = { somethingWithCost: Int @cost(weight: 20) } + scalar ExpensiveInt @cost(weight: 30) + + type ExpensiveObject @cost(weight: 40) { + id: ID + } + type Query { fieldWithCost: Int @cost(weight: 5) argWithCost(arg: Int @cost(weight: 10)): Int enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject } `), }; @@ -41,8 +50,13 @@ const subgraphWithListSize = { typeDefs: asFed2SubgraphDocument(gql` extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) + type HasInts { + ints: [Int!] + } + type Query { fieldWithListSize: [String!] @listSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) } `), }; @@ -61,11 +75,19 @@ const subgraphWithRenamedCost = { somethingWithCost: Int @renamedCost(weight: 20) } + scalar ExpensiveInt @renamedCost(weight: 30) + + type ExpensiveObject @renamedCost(weight: 40) { + id: ID + } + type Query { fieldWithCost: Int @renamedCost(weight: 5) argWithCost(arg: Int @renamedCost(weight: 10)): Int enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject } `), }; @@ -75,8 +97,13 @@ const subgraphWithRenamedListSize = { typeDefs: asFed2SubgraphDocument(gql` extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@listSize", as: "@renamedListSize" }]) + type HasInts { + ints: [Int!] @shareable + } + type Query { fieldWithListSize: [String!] @renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @renamedListSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) } `), }; @@ -94,11 +121,19 @@ const subgraphWithCostFromFederationSpec = { somethingWithCost: Int @cost(weight: 20) } + scalar ExpensiveInt @cost(weight: 30) + + type ExpensiveObject @cost(weight: 40) { + id: ID + } + type Query { fieldWithCost: Int @cost(weight: 5) argWithCost(arg: Int @cost(weight: 10)): Int enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject } `, { includeAllImports: true }, @@ -109,8 +144,13 @@ const subgraphWithListSizeFromFederationSpec = { name: 'subgraphWithListSize', typeDefs: asFed2SubgraphDocument( gql` + type HasInts { + ints: [Int!] + } + type Query { fieldWithListSize: [String!] @listSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) } `, { includeAllImports: true }, @@ -132,11 +172,19 @@ const subgraphWithRenamedCostFromFederationSpec = { somethingWithCost: Int @renamedCost(weight: 20) } + scalar ExpensiveInt @renamedCost(weight: 30) + + type ExpensiveObject @renamedCost(weight: 40) { + id: ID + } + type Query { fieldWithCost: Int @renamedCost(weight: 5) argWithCost(arg: Int @renamedCost(weight: 10)): Int enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject } `, }; @@ -147,8 +195,13 @@ const subgraphWithRenamedListSizeFromFederationSpec = { gql` extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: [{ name: "@listSize", as: "@renamedListSize" }]) + type HasInts { + ints: [Int!] + } + type Query { fieldWithListSize: [String!] @renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @renamedListSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) } `, }; @@ -193,7 +246,27 @@ function inputWithCost(result: CompositionResult): InputObjectType | undefined { ?.type as InputObjectType; } -// Used to test @listSize applications on FIELD_DEFINITION +// Used to test @cost applications on SCALAR +function scalarWithCost(result: CompositionResult): ScalarType | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('scalarWithCost') + ?.type as ScalarType +} + +// Used to test @cost applications on OBJECT +function objectWithCost(result: CompositionResult): ObjectType | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('objectWithCost') + ?.type as ObjectType +} + +// Used to test @listSize applications on FIELD_DEFINITION with a statically assumed size function fieldWithListSize(result: CompositionResult): FieldDefinition | undefined { return result .schema @@ -202,6 +275,15 @@ function fieldWithListSize(result: CompositionResult): FieldDefinition | undefined { + return result + .schema + ?.schemaDefinition + .rootType('query') + ?.field('fieldWithDynamicListSize'); +} + describe('demand control directive composition', () => { it.each([ [subgraphWithCost, subgraphWithListSize], @@ -223,8 +305,17 @@ describe('demand control directive composition', () => { const inputCostDirectiveApplications = inputWithCost(result)?.field('somethingWithCost')?.appliedDirectivesOf('cost'); expect(inputCostDirectiveApplications?.toString()).toMatchString(`@cost(weight: 20)`); + const scalarCostDirectiveApplications = scalarWithCost(result)?.appliedDirectivesOf('cost'); + expect(scalarCostDirectiveApplications?.toString()).toMatchString(`@cost(weight: 30)`); + + const objectCostDirectiveApplications = objectWithCost(result)?.appliedDirectivesOf('cost'); + expect(objectCostDirectiveApplications?.toString()).toMatchString(`@cost(weight: 40)`); + const listSizeDirectiveApplications = fieldWithListSize(result)?.appliedDirectivesOf('listSize'); expect(listSizeDirectiveApplications?.toString()).toMatchString(`@listSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + + const dynamicListSizeDirectiveApplications = fieldWithDynamicListSize(result)?.appliedDirectivesOf('listSize'); + expect(dynamicListSizeDirectiveApplications?.toString()).toMatchString(`@listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true)`); }); describe('when renamed', () => { @@ -252,8 +343,20 @@ describe('demand control directive composition', () => { const enumCostDirectiveApplications = enumWithCost(result)?.appliedDirectivesOf('renamedCost'); expect(enumCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 15)`); + const inputCostDirectiveApplications = inputWithCost(result)?.field('somethingWithCost')?.appliedDirectivesOf('renamedCost'); + expect(inputCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 20)`); + + const scalarCostDirectiveApplications = scalarWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(scalarCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 30)`); + + const objectCostDirectiveApplications = objectWithCost(result)?.appliedDirectivesOf('renamedCost'); + expect(objectCostDirectiveApplications?.toString()).toMatchString(`@renamedCost(weight: 40)`); + const listSizeDirectiveApplications = fieldWithListSize(result)?.appliedDirectivesOf('renamedListSize'); expect(listSizeDirectiveApplications?.toString()).toMatchString(`@renamedListSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + + const dynamicListSizeDirectiveApplications = fieldWithDynamicListSize(result)?.appliedDirectivesOf('renamedListSize'); + expect(dynamicListSizeDirectiveApplications?.toString()).toMatchString(`@renamedListSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true)`); }); }); @@ -406,6 +509,15 @@ describe('demand control directive extraction', () => { B } + scalar ExpensiveInt + @federation__cost(weight: 30) + + type ExpensiveObject + @federation__cost(weight: 40) + { + id: ID + } + input InputTypeWithCost { somethingWithCost: Int @federation__cost(weight: 20) } @@ -415,6 +527,8 @@ describe('demand control directive extraction', () => { argWithCost(arg: Int @federation__cost(weight: 10)): Int enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject } `); }); @@ -436,63 +550,15 @@ describe('demand control directive extraction', () => { query: Query } - type Query { - fieldWithListSize: [String!] @federation__listSize(assumedSize: 2000, requireOneSlicingArgument: false) - } - `); - }); - - it('extracts @listSize with dynamic cost arguments', () => { - const subgraphA = { - name: 'subgraph-a', - typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) - - type Query { - sizedList(first: Int!): HasInts @shareable @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) - } - - type HasInts { - ints: [Int!] @shareable - } - `) - }; - const subgraphB = { - name: 'subgraph-b', - typeDefs: asFed2SubgraphDocument(gql` - extend schema @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@listSize"]) - - type Query { - sizedList(first: Int!): HasInts @shareable @listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: false) - } - - type HasInts { - ints: [Int!] @shareable - } - `) - }; - - const result = composeServices([subgraphA, subgraphB]); - assertCompositionSuccess(result); - const supergraph = Supergraph.build(result.supergraphSdl); - - const expectedSubgraph = ` - schema - ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} - { - query: Query - } - type HasInts { - ints: [Int!] @shareable + ints: [Int!] } type Query { - sizedList(first: Int!): HasInts @shareable @federation__listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: false) + fieldWithListSize: [String!] @federation__listSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @federation__listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) } - `; - expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(expectedSubgraph); - expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(expectedSubgraph); + `); }); describe('when used on @shareable fields', () => { diff --git a/internals-js/src/__tests__/schemaUpgrader.test.ts b/internals-js/src/__tests__/schemaUpgrader.test.ts index f93b03b37..47bed9958 100644 --- a/internals-js/src/__tests__/schemaUpgrader.test.ts +++ b/internals-js/src/__tests__/schemaUpgrader.test.ts @@ -366,8 +366,8 @@ test('fully upgrades a schema with no @link directive', () => { test("don't add @shareable to subscriptions", () => { const subgraph1 = buildSubgraph( - "subgraph1", - "", + 'subgraph1', + '', `#graphql type Query { hello: String @@ -376,12 +376,12 @@ test("don't add @shareable to subscriptions", () => { type Subscription { update: String! } - ` + `, ); - + const subgraph2 = buildSubgraph( - "subgraph2", - "", + 'subgraph2', + '', `#graphql type Query { hello: String @@ -390,16 +390,30 @@ test("don't add @shareable to subscriptions", () => { type Subscription { update: String! } - ` + `, ); const subgraphs = new Subgraphs(); subgraphs.add(subgraph1); subgraphs.add(subgraph2); const result = upgradeSubgraphsIfNecessary(subgraphs); - expect(printSchema(result.subgraphs!.get("subgraph1")!.schema!)).not.toContain('update: String! @shareable'); - expect(printSchema(result.subgraphs!.get("subgraph2")!.schema!)).not.toContain('update: String! @shareable'); - - expect(result.subgraphs!.get("subgraph1")!.schema.type('Subscription')?.appliedDirectivesOf('@shareable').length).toBe(0); - expect(result.subgraphs!.get("subgraph2")!.schema.type('Subscription')?.appliedDirectivesOf('@shareable').length).toBe(0); + expect( + printSchema(result.subgraphs!.get('subgraph1')!.schema!), + ).not.toContain('update: String! @shareable'); + expect( + printSchema(result.subgraphs!.get('subgraph2')!.schema!), + ).not.toContain('update: String! @shareable'); + + expect( + result + .subgraphs!.get('subgraph1')! + .schema.type('Subscription') + ?.appliedDirectivesOf('@shareable').length, + ).toBe(0); + expect( + result + .subgraphs!.get('subgraph2')! + .schema.type('Subscription') + ?.appliedDirectivesOf('@shareable').length, + ).toBe(0); }); diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 1d8a2e4d3..99663291d 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -351,7 +351,8 @@ function addAllEmptySubgraphTypes(args: ExtractArguments): TypesInfo { for (const application of typeApplications) { const subgraph = getSubgraph(application); assert(subgraph, () => `Should have found the subgraph for ${application}`); - subgraph.schema.addType(newNamedType(type.kind, type.name)); + const subgraphType = subgraph.schema.addType(newNamedType(type.kind, type.name)); + propagateDemandControlDirectives(type, subgraphType, subgraph, args.originalDirectiveNames); } break; } @@ -449,6 +450,10 @@ function extractObjOrItfContent(args: ExtractArguments, info: TypeInfo Date: Wed, 14 Aug 2024 08:16:36 -0700 Subject: [PATCH 2/3] Skip directive extraction for custom directives which happen to conflict with default federation directive names (#3108) When we extract demand control directives from the supergraph to the query planner's subgraphs, we look for well-known directive names and copy instances of them over. When looking up the directive name from the imports, we would use the default expected name. If a custom directive with the default name was imported, we would wrongly try to copy it over as the federation directive. This change makes it so we only extract directives which are imported from `specs.apollo.dev`, such that we don't confuse a custom `@cost` directive with the one defined in federation. --- .../__tests__/compose.demandControl.test.ts | 123 +++++++++++++++++- .../src/extractSubgraphsFromSupergraph.ts | 53 ++++++-- 2 files changed, 161 insertions(+), 15 deletions(-) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index 62a5a4ebe..cf1bab81f 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -206,6 +206,36 @@ const subgraphWithRenamedListSizeFromFederationSpec = { `, }; +const subgraphWithUnimportedCost = { + name: 'subgraphWithCost', + typeDefs: asFed2SubgraphDocument(gql` + enum AorB @federation__cost(weight: 15) { + A + B + } + + input InputTypeWithCost { + somethingWithCost: Int @federation__cost(weight: 20) + } + + type Query { + fieldWithCost: Int @federation__cost(weight: 5) + argWithCost(arg: Int @federation__cost(weight: 10)): Int + enumWithCost: AorB + inputWithCost(someInput: InputTypeWithCost): Int + } + `), +}; + +const subgraphWithUnimportedListSize = { + name: 'subgraphWithListSize', + typeDefs: asFed2SubgraphDocument(gql` + type Query { + fieldWithListSize: [String!] @federation__listSize(assumedSize: 2000, requireOneSlicingArgument: false) + } + `), +}; + // Used to test @cost applications on FIELD_DEFINITION function fieldWithCost(result: CompositionResult): FieldDefinition | undefined { return result @@ -482,6 +512,29 @@ describe('demand control directive composition', () => { `); }); }); + + describe('when using fully qualified names instead of importing', () => { + it('propagates @federation__cost and @federation__listSize to the supergraph', () => { + const result = composeServices([subgraphWithUnimportedCost, subgraphWithUnimportedListSize]); + assertCompositionSuccess(result); + expect(result.hints).toEqual([]); + + const costDirectiveApplications = fieldWithCost(result)?.appliedDirectivesOf('federation__cost'); + expect(costDirectiveApplications?.toString()).toMatchString(`@federation__cost(weight: 5)`); + + const argCostDirectiveApplications = argumentWithCost(result)?.appliedDirectivesOf('federation__cost'); + expect(argCostDirectiveApplications?.toString()).toMatchString(`@federation__cost(weight: 10)`); + + const enumCostDirectiveApplications = enumWithCost(result)?.appliedDirectivesOf('federation__cost'); + expect(enumCostDirectiveApplications?.toString()).toMatchString(`@federation__cost(weight: 15)`); + + const inputCostDirectiveApplications = inputWithCost(result)?.field('somethingWithCost')?.appliedDirectivesOf('federation__cost'); + expect(inputCostDirectiveApplications?.toString()).toMatchString(`@federation__cost(weight: 20)`); + + const listSizeDirectiveApplications = fieldWithListSize(result)?.appliedDirectivesOf('federation__listSize'); + expect(listSizeDirectiveApplications?.toString()).toMatchString(`@federation__listSize(assumedSize: 2000, requireOneSlicingArgument: false)`); + }); + }) }); describe('demand control directive extraction', () => { @@ -489,11 +542,12 @@ describe('demand control directive extraction', () => { subgraphWithCost, subgraphWithRenamedCost, subgraphWithCostFromFederationSpec, - subgraphWithRenamedCostFromFederationSpec + subgraphWithRenamedCostFromFederationSpec, + subgraphWithUnimportedCost, ])('extracts @cost from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithCost.name); + const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraph.name); expect(extracted?.toString()).toMatchString(` schema @@ -537,11 +591,12 @@ describe('demand control directive extraction', () => { subgraphWithListSize, subgraphWithRenamedListSize, subgraphWithListSizeFromFederationSpec, - subgraphWithRenamedListSizeFromFederationSpec + subgraphWithRenamedListSizeFromFederationSpec, + subgraphWithUnimportedListSize, ])('extracts @listSize from the supergraph', (subgraph: ServiceDefinition) => { const result = composeServices([subgraph]); assertCompositionSuccess(result); - const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraphWithListSize.name); + const extracted = Supergraph.build(result.supergraphSdl).subgraphs().get(subgraph.name); expect(extracted?.toString()).toMatchString(` schema @@ -646,4 +701,64 @@ describe('demand control directive extraction', () => { expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(expectedSubgraph); }); }); + + describe('when the supergraph uses custom directives with the same name', () => { + it('does not attempt to extract them to the subgraphs', () => { + const subgraphA = { + name: 'subgraph-a', + typeDefs: asFed2SubgraphDocument(gql` + extend schema + @link(url: "https://example.com/myCustomDirective/v1.0", import: ["@cost"]) + @composeDirective(name: "@cost") + + directive @cost(name: String!) on FIELD_DEFINITION + + type Query { + a: Int @cost(name: "cost") + } + `) + }; + const subgraphB = { + name: 'subgraph-b', + typeDefs: asFed2SubgraphDocument(gql` + extend schema + @link(url: "https://example.com/myOtherCustomDirective/v1.0", import: ["@listSize"]) + @composeDirective(name: "@listSize") + + directive @listSize(name: String!) on FIELD_DEFINITION + + type Query { + b: [Int] @listSize(name: "listSize") + } + `) + }; + + const result = composeServices([subgraphA, subgraphB]); + assertCompositionSuccess(result); + const supergraph = Supergraph.build(result.supergraphSdl); + + expect(supergraph.subgraphs().get(subgraphA.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + a: Int + } + `); + expect(supergraph.subgraphs().get(subgraphB.name)?.toString()).toMatchString(` + schema + ${FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS} + { + query: Query + } + + type Query { + b: [Int] + } + `); + }); + }); }); diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 99663291d..3d412d039 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -40,7 +40,7 @@ import { parseSelectionSet } from "./operations"; import fs from 'fs'; import path from 'path'; import { validateStringContainsBoolean } from "./utils"; -import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, FederationDirectiveName, SchemaElement, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; +import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, FeatureUrl, FederationDirectiveName, SchemaElement, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; function filteredTypes( supergraph: Schema, @@ -224,7 +224,7 @@ export function extractSubgraphsFromSupergraph(supergraph: Schema, validateExtra } const types = filteredTypes(supergraph, joinSpec, coreFeatures.coreDefinition); - const originalDirectiveNames = getOriginalDirectiveNames(supergraph); + const originalDirectiveNames = getApolloDirectiveNames(supergraph); const args: ExtractArguments = { supergraph, subgraphs, @@ -485,13 +485,40 @@ function extractObjOrItfContent(args: ExtractArguments, info: TypeInfo { +/** + * Builds a map of original name to new name for Apollo feature directives. This is + * used to handle cases where a directive is renamed via an import statement. For + * example, importing a directive with a custom name like + * ```graphql + * @link(url: "https://specs.apollo.dev/cost/v0.1", import: [{ name: "@cost", as: "@renamedCost" }]) + * ``` + * results in a map entry of `cost -> renamedCost` with the `@` prefix removed. + * + * If the directive is imported under its default name, that also results in an entry. So, + * ```graphql + * @link(url: "https://specs.apollo.dev/cost/v0.1", import: ["@cost"]) + * ``` + * results in a map entry of `cost -> cost`. This duals as a way to check if a directive + * is included in the supergraph schema. + * + * **Important:** This map does _not_ include directives imported from identities other + * than `specs.apollo.dev`. This helps us avoid extracting directives to subgraphs + * when a custom directive's name conflicts with that of a default one. + */ +function getApolloDirectiveNames(supergraph: Schema): Record { const originalDirectiveNames: Record = {}; for (const linkDirective of supergraph.schemaDefinition.appliedDirectivesOf("link")) { if (linkDirective.arguments().url && linkDirective.arguments().import) { + const url = FeatureUrl.maybeParse(linkDirective.arguments().url); + if (!url?.identity.includes("specs.apollo.dev")) { + continue; + } + for (const importedDirective of linkDirective.arguments().import) { if (importedDirective.name && importedDirective.as) { originalDirectiveNames[importedDirective.name.replace('@', '')] = importedDirective.as.replace('@', ''); + } else if (typeof importedDirective === 'string') { + originalDirectiveNames[importedDirective.replace('@', '')] = importedDirective.replace('@', ''); } } } @@ -652,16 +679,20 @@ function maybeDumpSubgraphSchema(subgraph: Subgraph): string { } function propagateDemandControlDirectives(source: SchemaElement, dest: SchemaElement, subgraph: Subgraph, originalDirectiveNames?: Record) { - const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST] ?? FederationDirectiveName.COST; - const costDirective = source.appliedDirectivesOf(costDirectiveName).pop(); - if (costDirective) { - dest.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); + const costDirectiveName = originalDirectiveNames?.[FederationDirectiveName.COST]; + if (costDirectiveName) { + const costDirective = source.appliedDirectivesOf(costDirectiveName).pop(); + if (costDirective) { + dest.applyDirective(subgraph.metadata().costDirective().name, costDirective.arguments()); + } } - const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE] ?? FederationDirectiveName.LIST_SIZE; - const listSizeDirective = source.appliedDirectivesOf(listSizeDirectiveName).pop(); - if (listSizeDirective) { - dest.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); + const listSizeDirectiveName = originalDirectiveNames?.[FederationDirectiveName.LIST_SIZE]; + if (listSizeDirectiveName) { + const listSizeDirective = source.appliedDirectivesOf(listSizeDirectiveName).pop(); + if (listSizeDirective) { + dest.applyDirective(subgraph.metadata().listSizeDirective().name, listSizeDirective.arguments()); + } } } From 1d14e32cbd97aa5b90e6af4a319d634043200bee Mon Sep 17 00:00:00 2001 From: Taylor Ninesling Date: Wed, 14 Aug 2024 08:32:54 -0700 Subject: [PATCH 3/3] Document new demand control directives (#3111) This pull request documents the directives added in https://github.com/apollographql/federation/pull/3074. These will be released in the next federation version (v2.9), which is targeted for the end of the month. Both directives are inspired by the [IBM cost specification](https://ibm.github.io/graphql-specs/cost-spec.html#sec-The-Cost-Directive). So, most of the documentation parrots their specification. --------- Co-authored-by: Edward Huang --- .../__tests__/compose.demandControl.test.ts | 13 ++ .../federated-directives.mdx | 211 ++++++++++++++++++ docs/source/federation-versions.mdx | 74 ++++++ 3 files changed, 298 insertions(+) diff --git a/composition-js/src/__tests__/compose.demandControl.test.ts b/composition-js/src/__tests__/compose.demandControl.test.ts index cf1bab81f..e2c2279b3 100644 --- a/composition-js/src/__tests__/compose.demandControl.test.ts +++ b/composition-js/src/__tests__/compose.demandControl.test.ts @@ -218,11 +218,19 @@ const subgraphWithUnimportedCost = { somethingWithCost: Int @federation__cost(weight: 20) } + scalar ExpensiveInt @federation__cost(weight: 30) + + type ExpensiveObject @federation__cost(weight: 40) { + id: ID + } + type Query { fieldWithCost: Int @federation__cost(weight: 5) argWithCost(arg: Int @federation__cost(weight: 10)): Int enumWithCost: AorB inputWithCost(someInput: InputTypeWithCost): Int + scalarWithCost: ExpensiveInt + objectWithCost: ExpensiveObject } `), }; @@ -230,8 +238,13 @@ const subgraphWithUnimportedCost = { const subgraphWithUnimportedListSize = { name: 'subgraphWithListSize', typeDefs: asFed2SubgraphDocument(gql` + type HasInts { + ints: [Int!] + } + type Query { fieldWithListSize: [String!] @federation__listSize(assumedSize: 2000, requireOneSlicingArgument: false) + fieldWithDynamicListSize(first: Int!): HasInts @federation__listSize(slicingArguments: ["first"], sizedFields: ["ints"], requireOneSlicingArgument: true) } `), }; diff --git a/docs/source/federated-schemas/federated-directives.mdx b/docs/source/federated-schemas/federated-directives.mdx index a8b009c2e..5a01af6f1 100644 --- a/docs/source/federated-schemas/federated-directives.mdx +++ b/docs/source/federated-schemas/federated-directives.mdx @@ -982,3 +982,214 @@ The selection syntax for `@fromContext` used in its `ContextFieldValue` is simil When the same contextual value is set in multiple places, the `ContextFieldValue` must resolve all types from each place into a single value that matches the parameter type. For examples using `@context` and `@fromContext`, see [Using contexts to share data along type hierarchies](../entities/use-contexts). + +## Customizing demand controls + + + +### `@cost` + + + + + +```graphql +directive @cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR +``` + +The `@cost` directive defines a custom weight for a schema location. For GraphOS Router, it customizes the operation cost calculation of the [demand control feature](/router/executing-operations/demand-control/). + +If `@cost` is not specified for a field, a default value is used: +- Scalars and enums have default cost of 0 +- Composite input and output types have default cost of 1 + +Regardless of whether `@cost` is specified on a field, the field cost for that field also accounts for its arguments and selections. + +#### Arguments + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +##### `weight` + +`Int!` + + +**Required.** Assigns a custom weight for scoring the current field. + +
+ + + +### `@listSize` + + + + + +```graphql +directive @listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +``` + +The `@listSize` directive is used to customize the cost calculation of the [demand control feature](/router/executing-operations/demand-control/) of GraphOS Router. + +In the static analysis phase, the cost calculator does not know how many entities will be returned by each list field in a given query. By providing an estimated list size for a field with `@listSize`, the cost calculator can produce a more accurate estimate the cost during static analysis. + +#### Configuring static list sizes + +The simplest way to define a list size for a field is to use the `assumedSize` argument. This defines a static assumed maximum length for a given list field in the schema. + +```graphql +type Query { + items: [Item!] @listSize(assumedSize: 10) +} + +type Item @key(fields: "id") { + id: ID +} +``` + +In this case, all queries for `items` are expected to receive at most ten items in the list. + +#### Configuring dynamic list sizes + +When using paging parameters, the length of a list field can be determined by an input value. You can use the `slicingArguments` argument to tell the router to expect as many elements as the query requests. + +```graphql +type Query { + items(first: Int, last: Int): [Item!] @listSize(slicingArguments: ["first", "last"], requireOneSlicingArgument: false) +} +``` + +In this example, the `items` field can be requested with paging parameters. If the client sends a query with multiple slicing arguments, the scoring algorithm will use the maximum value of all specified slicing arguments. The following query is assumed to return ten items in the scoring algorithm. + +```graphql +query MultipleSlicingArgumentsQuery { + items(first: 5, last: 10) +} +``` + +In some cases, you may want to enforce that only one slicing argument is used. For example, you may want to ensure that clients request either the first _n_ items or the last _n_ items, but not both. You can do this by setting `requireOneSlicingArgument` to `true`. + +```graphql +type Query { + items(first: Int, last: Int): [Item!] @listSize(slicingArguments: ["first", "last"], requireOneSlicingArgument: true) +} +``` + +With this updated schema, sending the the above `MultipleSlicingArgumentsQuery` with its two slicing arguments to a graph would result in an error, as would sending a query with no slicing arguments. + +#### Cursor support + +Some pagination patterns include extra information along with the requested entities. For example, we may have some schema with a cursor type. + +```graphql +type Query { + items(first: Int): Cursor! @listSize(slicingArguments: ["first"], sizedFields: ["page"]) +} + +type Cursor { + page: [Item!] + nextPageToken: String +} + +type Item @key(fields: "id") { + id: ID +} +``` + +This application of `@listSize` indicates that the length of the `page` field inside `Cursor` is determined by the `first` argument. + + +#### Arguments + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name /
Type
Description
+ +##### `assumedSize` + +`Int` + + +Indicates that the annotated list field will return at most this many items. + +
+ +##### `slicingArguments` + +`[String!]` + + + +Indicates that the annotated list field returns as many items as are requested by a paging argument. If multiple arguments are passed, the maximum value of the arguments is used. + +If both this and `assumedSize` are specified, the value from `slicingArguments` will take precedence. + +
+ +##### `sizedFields` + +`[String!]` + + +Supports cursor objects by indicating that the expected list size should be applied to fields within the returned object. + +
+ +##### `requireOneSlicingArgument` + +`Boolean` + + +If `true`, indicates that queries must supply exactly one argument from `slicingArguments`. + +If `slicingArguments` are not specified, this value is ignored. + +The default value is `true`. + +
diff --git a/docs/source/federation-versions.mdx b/docs/source/federation-versions.mdx index 13cbb03bc..b1cebbb1f 100644 --- a/docs/source/federation-versions.mdx +++ b/docs/source/federation-versions.mdx @@ -26,6 +26,80 @@ For a comprehensive changelog for Apollo Federation and its associated libraries - If you maintain a [subgraph-compatible library](./building-supergraphs/compatible-subgraphs/), consult this article to stay current with recently added directives. All of these directive definitions are also listed in the [subgraph specification](./subgraph-spec/#subgraph-schema-additions). +## v2.9 + +
+ + + +
+ +First release + +**August 2024** + +
+ +
+ +Minimum router version + +**TBD** + +
+ +
+ +
+ +#### Directive changes + + + + + + + + + + + + + + + + + + + + + +
TopicDescription
+ +#### `@cost` + + + +Introduced. [Learn more](./federated-types/federated-directives/#cost). + +```graphql +directive @cost(weight: Int!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR +``` + +
+ +#### `@listSize` + + + +Introduced. [Learn more](./federated-types/federated-directives/#listsize). + +```graphql +directive @listSize(assumedSize: Int, slicingArguments: [String!], sizedFields: [String!], requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION +``` + +
+ ## v2.8