From 884befc849d800837774b0c800146d3d89789fa8 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Wed, 31 Jan 2024 10:30:10 -0600 Subject: [PATCH 01/82] Some work on setContext. Mostly defining the directives and changes to the join spec. --- .../src/__tests__/compose.setContext.test.ts | 53 +++++++++++++++++++ composition-js/src/__tests__/compose.test.ts | 20 ++++++- .../src/__tests__/override.compose.test.ts | 10 +++- composition-js/src/merging/merge.ts | 52 ++++++++++++++++++ .../__tests__/gateway/lifecycle-hooks.test.ts | 2 +- .../src/__tests__/subgraphValidation.test.ts | 2 +- internals-js/src/error.ts | 7 +++ internals-js/src/federation.ts | 20 ++++++- internals-js/src/specs/federationSpec.ts | 16 ++++++ internals-js/src/specs/joinSpec.ts | 20 ++++++- 10 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 composition-js/src/__tests__/compose.setContext.test.ts diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts new file mode 100644 index 000000000..5192b4950 --- /dev/null +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -0,0 +1,53 @@ +import gql from 'graphql-tag'; +import { + assertCompositionSuccess, + composeAsFed2Subgraphs, +} from "./testHelper"; + +describe('setContext tests', () => { + test('contextual argument does not appear in all subgraphs', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") { + id: ID! + u: U! # @setContext here + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @require(fromContext: "context", field: "{ prop }") + ): Int! @shareable + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! + ): Int! @shareable + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + + }); +}) diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index c3770a657..2ce29d7f1 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -80,7 +80,7 @@ describe('composition', () => { 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) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, requiredArguments: [join__RequireArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE @@ -108,6 +108,14 @@ describe('composition', () => { SUBGRAPH2 @join__graph(name: "Subgraph2", url: "https://Subgraph2") } + input join__RequireArgument { + position: Int! + fromContext: String! + name: String! + type: String! + selection: join__FieldSet! + } + scalar link__Import enum link__Purpose { @@ -232,7 +240,7 @@ describe('composition', () => { 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) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, requiredArguments: [join__RequireArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE @@ -260,6 +268,14 @@ describe('composition', () => { SUBGRAPH2 @join__graph(name: "Subgraph2", url: "https://Subgraph2") } + input join__RequireArgument { + fromContext: String! + name: String! + position: Int! + selection: join__FieldSet! + type: String! + } + scalar link__Import enum link__Purpose { diff --git a/composition-js/src/__tests__/override.compose.test.ts b/composition-js/src/__tests__/override.compose.test.ts index f2d83878d..8c54f2765 100644 --- a/composition-js/src/__tests__/override.compose.test.ts +++ b/composition-js/src/__tests__/override.compose.test.ts @@ -984,7 +984,7 @@ describe("composition involving @override directive", () => { 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) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, requiredArguments: [join__RequireArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE @@ -1005,6 +1005,14 @@ describe("composition involving @override directive", () => { SUBGRAPH2 @join__graph(name: \\"Subgraph2\\", url: \\"https://Subgraph2\\") } + input join__RequireArgument { + position: Int! + fromContext: String! + name: String! + type: String! + selection: join__FieldSet! + } + scalar link__Import enum link__Purpose { diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 2a6682360..e6774e140 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -74,6 +74,7 @@ import { LinkDirectiveArgs, sourceIdentity, FeatureUrl, + isFederationDirectiveDefinedInSchema, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -1649,6 +1650,33 @@ class Merger { continue; } + const requiredArguments: { + position: number, + fromContext: string, + name: string, + type: string, + selection: string, + }[] = []; + const requireDirective = this.subgraphs.values()[idx].metadata().requiresDirective(); + if (source.kind === 'FieldDefinition') { + const args = source.arguments(); + args.forEach((arg, i) => { + const appliedDirectives = arg.appliedDirectivesOf(requireDirective); + assert(appliedDirectives.length <= 1, () => `@require directive should not be repeatable on ${arg.coordinate}`); + if (appliedDirectives.length === 1) { + const app = appliedDirectives[0]; + const argType = arg.type?.toString(); + assert(argType, () => `Argument ${arg.coordinate} should have a type`); + requiredArguments.push({ + position: i, + fromContext: app.arguments().fields['fromContext'], + name: app.arguments().fields['from'], + type: argType, + selection: app.arguments().fields['selection'], + }); + } + }); + } const external = this.isExternal(idx, source); const sourceMeta = this.subgraphs.values()[idx].metadata(); const name = this.joinSpecName(idx); @@ -1661,6 +1689,7 @@ class Merger { external: external ? true : undefined, usedOverridden: usedOverridden ? true : undefined, overrideLabel: mergeContext.overrideLabel(idx), + requiredArguments: requiredArguments.length > 0 ? requiredArguments : undefined, }); } } @@ -1787,6 +1816,29 @@ class Merger { // some path. Done because this helps reusing our "reportMismatchHint" method // in those cases. const arg = dest.addArgument(argName); + + const isContextualArg = (index: number, arg: ArgumentDefinition> | ArgumentDefinition>) => { + const requireDirective = this.metadata(index).requireDirective(); + return requireDirective && isFederationDirectiveDefinedInSchema(requireDirective) && arg.appliedDirectivesOf(requireDirective).length >= 1; + } + const hasContextual = sources.map((s, idx) => { + const arg = s?.argument(argName); + return arg && isContextualArg(idx, arg); + }); + + if (hasContextual.some((c) => c === true)) { + // If any of the sources has a contextual argument, then we need to remove it from the supergraph + // and ensure that all the sources have it. + if (hasContextual.some((c) => c === false)) { + this.errors.push(ERRORS.CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS.err( + `Argument "${arg.coordinate}" is contextual in some subgraphs but not in all subgraphs: it is contextual in ${printSubgraphNames(hasContextual.map((c, i) => c ? this.names[i] : undefined).filter(isDefined))} but not in ${printSubgraphNames(hasContextual.map((c, i) => c ? undefined : this.names[i]).filter(isDefined))}`, + { nodes: sourceASTs(...sources.map((s) => s?.argument(argName))) }, + )); + } + arg.remove(); + continue; + } + // If all the sources that have the field have the argument, we do merge it // and we're good, but otherwise ... if (sources.some((s) => s && !s.argument(argName))) { diff --git a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts index f660ca30c..b2087fe33 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -149,7 +149,7 @@ describe('lifecycle hooks', () => { // the supergraph (even just formatting differences), this ID will change // and this test will have to updated. expect(secondCall[0]!.compositionId).toMatchInlineSnapshot( - `"208492fefbbc8f62458b3f698b047a7c119f5169d4ccaef4c24b81a1ba82f87c"`, + `"163b5d73231206e3f72d963481b77cdac286cefaa9a49ae4dd371c863059dc94"`, ); // second call should have previous info in the second arg expect(secondCall[1]!.compositionId).toEqual(expectedFirstId); diff --git a/internals-js/src/__tests__/subgraphValidation.test.ts b/internals-js/src/__tests__/subgraphValidation.test.ts index dc80f9d0a..9232ff84a 100644 --- a/internals-js/src/__tests__/subgraphValidation.test.ts +++ b/internals-js/src/__tests__/subgraphValidation.test.ts @@ -1230,7 +1230,7 @@ describe('@interfaceObject/@key on interfaces validation', () => { // if you have an @interfaceObject, some other subgraph needs to be able to resolve the concrete // type, and that imply that you have key to go to that other subgraph. // To be clear, the @key on the @interfaceObject technically con't need to be "resolvable", and the - // difference between no key and a non-resolvable key is arguably more convention than a genuine + // difference between no key and a non-resolvable key is arguably more convention than a genuine // mechanical difference at the moment, but still a good idea to rely on that convention to help // catching obvious mistakes early. it('only allow @interfaceObject on entity types', () => { diff --git a/internals-js/src/error.ts b/internals-js/src/error.ts index aac2c205a..9958aad2f 100644 --- a/internals-js/src/error.ts +++ b/internals-js/src/error.ts @@ -674,6 +674,12 @@ const SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD = makeCodeDefinition( { addedIn: '2.7.0' }, ); +const CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS = makeCodeDefinition( + 'CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS', + 'Argument on field is marked contextual in only some subgraphs', + { addedIn: '2.7.0' }, +); + export const ERROR_CATEGORIES = { DIRECTIVE_FIELDS_MISSING_EXTERNAL, DIRECTIVE_UNSUPPORTED_ON_INTERFACE, @@ -782,6 +788,7 @@ export const ERRORS = { SOURCE_FIELD_HTTP_BODY_INVALID, SOURCE_FIELD_SELECTION_INVALID, SOURCE_FIELD_NOT_ON_ROOT_OR_ENTITY_FIELD, + CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS, }; const codeDefByCode = Object.values(ERRORS).reduce((obj: {[code: string]: ErrorCodeDefinition}, codeDef: ErrorCodeDefinition) => { obj[codeDef.code] = codeDef; return obj; }, {}); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index d9541a3cb..5cc592220 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -790,6 +790,14 @@ export class FederationMetadata { return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_FIELD); } + requireDirective(): Post20FederationDirectiveDefinition { + return this.getPost20FederationDirective(FederationDirectiveName.REQUIRE); + } + + setContextDirective(): Post20FederationDirectiveDefinition { + return this.getPost20FederationDirective(FederationDirectiveName.SET_CONTEXT); + } + allFederationDirectives(): DirectiveDefinition[] { const baseDirectives: DirectiveDefinition[] = [ this.keyDirective(), @@ -843,6 +851,16 @@ export class FederationMetadata { baseDirectives.push(sourceFieldDirective); } + const requireDirective = this.requireDirective(); + if (isFederationDirectiveDefinedInSchema(requireDirective)) { + baseDirectives.push(requireDirective); + } + + const setContextDirective = this.setContextDirective(); + if (isFederationDirectiveDefinedInSchema(setContextDirective)) { + baseDirectives.push(setContextDirective); + } + return baseDirectives; } @@ -1225,7 +1243,7 @@ export function setSchemaAsFed2Subgraph(schema: Schema) { // This is the full @link declaration as added by `asFed2SubgraphDocument`. It's here primarily for uses by tests that print and match // subgraph schema to avoid having to update 20+ tests every time we use a new directive or the order of import changes ... -export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField"])'; +export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@setContext", "@require"])'; // This is the full @link declaration that is added when upgrading fed v1 subgraphs to v2 version. It should only be used by tests. export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index b717a9e46..687fd99a4 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -44,6 +44,8 @@ export enum FederationDirectiveName { SOURCE_API = 'sourceAPI', SOURCE_TYPE = 'sourceType', SOURCE_FIELD = 'sourceField', + SET_CONTEXT = 'setContext', + REQUIRE = 'require', } const fieldSetTypeSpec = createScalarTypeSpecification({ name: FederationTypeName.FIELD_SET }); @@ -87,6 +89,18 @@ const legacyFederationTypes = [ fieldSetTypeSpec, ]; +const setContextSpec = createDirectiveSpecification({ + name: FederationDirectiveName.SET_CONTEXT, + locations: [DirectiveLocation.FIELD_DEFINITION], + args: [{ name: 'name', type: (schema) =>new NonNullType(schema.stringType()) }, { name: 'field', type: (schema) => fieldSetType(schema) }], +}); + +const requireSpec = createDirectiveSpecification({ + name: FederationDirectiveName.REQUIRE, + locations: [DirectiveLocation.ARGUMENT_DEFINITION], + args: [{ name: 'fromContext', type: (schema) =>new NonNullType(schema.stringType()) }, { name: 'field', type: (schema) => fieldSetType(schema) }], +}); + const legacyFederationDirectives = [ keyDirectiveSpec, requiresDirectiveSpec, @@ -173,6 +187,8 @@ export class FederationSpecDefinition extends FeatureDefinition { if (version.gte(new FeatureVersion(2, 7))) { this.registerSubFeature(SOURCE_VERSIONS.find(new FeatureVersion(0, 1))!); + this.registerDirective(setContextSpec); + this.registerDirective(requireSpec); } } } diff --git a/internals-js/src/specs/joinSpec.ts b/internals-js/src/specs/joinSpec.ts index 5d297958f..38be87608 100644 --- a/internals-js/src/specs/joinSpec.ts +++ b/internals-js/src/specs/joinSpec.ts @@ -7,6 +7,7 @@ import { Schema, NonNullType, ListType, + InputObjectType, } from "../definitions"; import { Subgraph, Subgraphs } from "../federation"; import { registerKnownFeature } from '../knownCoreFeatures'; @@ -48,6 +49,13 @@ export type JoinFieldDirectiveArguments = { external?: boolean, usedOverridden?: boolean, overrideLabel?: string, + requiredArguments?: { + position: number, + fromContext: string, + name: string, + type: string, + selection: string, + }[], } export type JoinDirectiveArguments = { @@ -151,8 +159,18 @@ export class JoinSpecDefinition extends FeatureDefinition { joinDirective.addArgument('name', new NonNullType(schema.stringType())); joinDirective.addArgument('args', this.addScalarType(schema, 'DirectiveArguments')); - //progressive override + // progressive override joinField.addArgument('overrideLabel', schema.stringType()); + + // set context + const requireType = schema.addType(new InputObjectType('join__RequireArgument')); + requireType.addField('position', new NonNullType(schema.intType())); + requireType.addField('fromContext', new NonNullType(schema.stringType())); + requireType.addField('name', new NonNullType(schema.stringType())); + requireType.addField('type', new NonNullType(schema.stringType())); + requireType.addField('selection', new NonNullType(joinFieldSet)); + + joinField.addArgument('requiredArguments', new ListType(new NonNullType(requireType))); } if (this.isV01()) { From 4561844ec27b8e007440945e996076527929bc82 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 12 Mar 2024 09:59:52 -0500 Subject: [PATCH 02/82] First pass at context validation. This will become obsolete since we are going to do subgraph validation rather than supergraph, but I wanted to get it checked in before I start on the next phase. --- .../src/__tests__/compose.setContext.test.ts | 150 +++++++++++++++++- composition-js/src/merging/merge.ts | 125 ++++++++++++--- internals-js/src/error.ts | 21 +++ internals-js/src/federation.ts | 28 ++-- internals-js/src/specs/federationSpec.ts | 29 ++-- internals-js/src/specs/joinSpec.ts | 10 +- 6 files changed, 309 insertions(+), 54 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 5192b4950..5c8105ce9 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -3,9 +3,55 @@ import { assertCompositionSuccess, composeAsFed2Subgraphs, } from "./testHelper"; +import { printSchema } from '@apollo/federation-internals'; describe('setContext tests', () => { - test('contextual argument does not appear in all subgraphs', () => { + test('vanilla setContext works', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + + const schema = result.schema; + console.log(printSchema(schema)); + }); + + it('context is never set', () => { const subgraph1 = { name: 'Subgraph1', utl: 'https://Subgraph1', @@ -16,15 +62,15 @@ describe('setContext tests', () => { type T @key(fields: "id") { id: ID! - u: U! # @setContext here + u: U! prop: String! } type U @key(fields: "id") { id: ID! field ( - a: String! @require(fromContext: "context", field: "{ prop }") - ): Int! @shareable + a: String! @fromContext(field: "$nocontext { prop }") + ): Int! } ` }; @@ -37,17 +83,107 @@ describe('setContext tests', () => { a: Int! } + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('Context "nocontext" is used in "U.field(a:)" but is never set in any subgraph.'); + }); + + it('resolved field is not available in context', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + type U @key(fields: "id") { id: ID! field ( - a: String! - ): Int! @shareable + a: String! @fromContext(field: "$context { invalidprop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! } ` }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); - assertCompositionSuccess(result); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: Cannot query field \"invalidprop\" on type \"T\".'); + }); + + it('context variable does not appear in selection', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "{ prop }") + ): Int! + } + ` + }; + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('@fromContext argument does not reference a context \"{ prop }\".'); }); + it.todo('type mismatch in context variable'); + it.todo('nullability mismatch is ok if contextual value is non-nullable') + it.todo('nullability mismatch is not ok if argument is non-nullable') + it.todo('selection contains more than one value'); }) diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index e6774e140..78ad473d4 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -75,6 +75,7 @@ import { sourceIdentity, FeatureUrl, isFederationDirectiveDefinedInSchema, + parseSelectionSet, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -305,6 +306,7 @@ class Merger { private latestFedVersionUsed: FeatureVersion; private joinDirectiveIdentityURLs = new Set(); private schemaToImportNameToFeatureUrl = new Map>(); + private contextToTypeMap = new Map, usages: { usage: string, argumentDefinition: ArgumentDefinition> }[] }>(); constructor(readonly subgraphs: Subgraphs, readonly options: CompositionOptions) { this.latestFedVersionUsed = this.getLatestFederationVersionUsed(); @@ -819,6 +821,25 @@ class Merger { } } + private addTypeToContextMap(type: CompositeType, contexts: string[]) { + for (const context of contexts) { + if (this.contextToTypeMap.has(context)) { + this.contextToTypeMap.get(context)!.types.add(type); + } else { + this.contextToTypeMap.set(context, { types: new Set([type]), usages: [] }); + } + + } + } + + private addUsageToContextMap(context: string, selection: string, argumentDefinition: ArgumentDefinition>) { + if (this.contextToTypeMap.has(context)) { + this.contextToTypeMap.get(context)!.usages.push({ usage: selection, argumentDefinition }); + } else { + this.contextToTypeMap.set(context, { types: new Set(), usages: [{ usage: selection, argumentDefinition }] }); + } + } + private addJoinType(sources: (NamedType | undefined)[], dest: NamedType) { const joinTypeDirective = this.joinSpec.typeDirective(this.merged); for (const [idx, source] of sources.entries()) { @@ -834,13 +855,23 @@ class Merger { const isInterfaceObject = sourceMetadata.isInterfaceObjectType(source) ? true : undefined; const keys = source.appliedDirectivesOf(sourceMetadata.keyDirective()); const name = this.joinSpecName(idx); + const contextDirective = sourceMetadata.contextDirective(); + let contexts: string[] | undefined = undefined; + if (isFederationDirectiveDefinedInSchema(contextDirective) && isCompositeType(dest)) { + const appliedDirectives = source.appliedDirectivesOf(contextDirective); + if (appliedDirectives.length > 0) { + contexts = appliedDirectives.map(d => d.arguments().name); + this.addTypeToContextMap(dest, contexts); + } + } + if (!keys.length) { dest.applyDirective(joinTypeDirective, { graph: name, isInterfaceObject }); } else { for (const key of keys) { const extension = key.ofExtension() || source.hasAppliedDirective(sourceMetadata.extendsDirective()) ? true : undefined; const { resolvable } = key.arguments(); - dest.applyDirective(joinTypeDirective, { graph: name, key: key.arguments().fields, extension, resolvable, isInterfaceObject }); + dest.applyDirective(joinTypeDirective, { graph: name, key: key.arguments().fields, extension, resolvable, isInterfaceObject, contexts }); } } } @@ -1651,29 +1682,36 @@ class Merger { } const requiredArguments: { - position: number, - fromContext: string, + context: string, name: string, type: string, selection: string, }[] = []; - const requireDirective = this.subgraphs.values()[idx].metadata().requiresDirective(); - if (source.kind === 'FieldDefinition') { + const metadata = this.subgraphs.values()[idx].metadata(); + const fromContextDirective = metadata.fromContextDirective(); + if (source.kind === 'FieldDefinition' && isFederationDirectiveDefinedInSchema(fromContextDirective)) { const args = source.arguments(); - args.forEach((arg, i) => { - const appliedDirectives = arg.appliedDirectivesOf(requireDirective); - assert(appliedDirectives.length <= 1, () => `@require directive should not be repeatable on ${arg.coordinate}`); + args.forEach((arg) => { + const appliedDirectives = arg.appliedDirectivesOf(fromContextDirective); + assert(appliedDirectives.length <= 1, () => `@fromContext directive should not be repeatable on ${arg.coordinate}`); if (appliedDirectives.length === 1) { - const app = appliedDirectives[0]; const argType = arg.type?.toString(); assert(argType, () => `Argument ${arg.coordinate} should have a type`); - requiredArguments.push({ - position: i, - fromContext: app.arguments().fields['fromContext'], - name: app.arguments().fields['from'], - type: argType, - selection: app.arguments().fields['selection'], - }); + const { context, selection } = this.parseContext(appliedDirectives[0].arguments().field); + if (!context || !selection) { + this.errors.push(ERRORS.NO_CONTEXT_IN_SELECTION.err( + `@fromContext argument does not reference a context "${appliedDirectives[0].arguments().field}".`, + { nodes: sourceASTs(appliedDirectives[0]) } + )); + } else { + this.addUsageToContextMap(context, selection, arg); + requiredArguments.push({ + name: arg.name, + type: argType, + context, + selection, + }); + } } }); } @@ -1694,6 +1732,20 @@ class Merger { } } + private parseContext = (input: string): { context: string | undefined, selection: string | undefined } => { + const regex = /^\$([\w\d_]+)\s*({[\s\S]+})$/; + const match = input.match(regex); + if (!match) { + return { context: undefined, selection: undefined }; + } + + const [, context, selection] = match; + return { + context, + selection, + }; + }; + private getFieldSet(element: SchemaElement, directive: DirectiveDefinition<{fields: string}>): string | undefined { const applications = element.appliedDirectivesOf(directive); assert(applications.length <= 1, () => `Found more than one application of ${directive} on ${element}`); @@ -1818,8 +1870,8 @@ class Merger { const arg = dest.addArgument(argName); const isContextualArg = (index: number, arg: ArgumentDefinition> | ArgumentDefinition>) => { - const requireDirective = this.metadata(index).requireDirective(); - return requireDirective && isFederationDirectiveDefinedInSchema(requireDirective) && arg.appliedDirectivesOf(requireDirective).length >= 1; + const fromContextDirective = this.metadata(index).fromContextDirective(); + return fromContextDirective && isFederationDirectiveDefinedInSchema(fromContextDirective) && arg.appliedDirectivesOf(fromContextDirective).length >= 1; } const hasContextual = sources.map((s, idx) => { const arg = s?.argument(argName); @@ -2972,7 +3024,44 @@ class Merger { } } } + this.validateContextUsages(); + } + // private traverseSelectionSetForType( + // selection: string, + // type: ObjectType, + // ) { + // const selectionSet = new SelectionSet(type, ) + // } + + private validateContextUsages() { + // For each usage of a context, we need to validate that all set contexts could fulfill the selection of the context + this.contextToTypeMap.forEach(({ usages, types }, context) => { + for (const { usage, argumentDefinition } of usages) { + if (types.size === 0) { + this.errors.push(ERRORS.CONTEXT_NOT_SET.err( + `Context "${context}" is used in "${argumentDefinition.coordinate}" but is never set in any subgraph.`, + { nodes: sourceASTs(argumentDefinition) } + )); + } + // const resolvedTypes = []; + for (const type of types) { + // now ensure that for each type, the selection is satisfiable and collect the resolved type + try { + parseSelectionSet({ parentType: type, source: usage }); + } catch (error) { + if (error instanceof GraphQLError) { + this.errors.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${argumentDefinition.coordinate}" but the selection is invalid: ${error.message}`, + { nodes: sourceASTs(argumentDefinition) } + )); + } else { + throw error; + } + } + } + } + }); } private updateInaccessibleErrorsWithLinkToSubgraphs( diff --git a/internals-js/src/error.ts b/internals-js/src/error.ts index 9958aad2f..7f3ddf620 100644 --- a/internals-js/src/error.ts +++ b/internals-js/src/error.ts @@ -325,6 +325,24 @@ const TYPE_KIND_MISMATCH = makeCodeDefinition( { ...DEFAULT_METADATA, replaces: ['VALUE_TYPE_KIND_MISMATCH', 'EXTENSION_OF_WRONG_KIND', 'ENUM_MISMATCH_TYPE'] }, ); +const CONTEXT_NOT_SET = makeCodeDefinition( + 'CONTEXT_NOT_SET', + 'Context is never set for context trying to be used.', + { addedIn: '2.8.0' }, +); + +const CONTEXT_INVALID_SELECTION= makeCodeDefinition( + 'CONTEXT_INVALID_SELECTION', + 'Selection within a context is not valid for contextual type.', + { addedIn: '2.8.0' }, +); + +const NO_CONTEXT_IN_SELECTION = makeCodeDefinition( + 'NO_CONTEXT_IN_SELECTION', + 'Selection in @fromContext field argument does not reference a context.', + { addedIn: '2.8.0' }, +); + const EXTERNAL_TYPE_MISMATCH = makeCodeDefinition( 'EXTERNAL_TYPE_MISMATCH', 'An `@external` field has a type that is incompatible with the declaration(s) of that field in other subgraphs.', @@ -722,6 +740,9 @@ export const ERRORS = { NO_QUERIES, INTERFACE_FIELD_NO_IMPLEM, TYPE_KIND_MISMATCH, + CONTEXT_NOT_SET, + CONTEXT_INVALID_SELECTION, + NO_CONTEXT_IN_SELECTION, EXTERNAL_TYPE_MISMATCH, EXTERNAL_ARGUMENT_MISSING, EXTERNAL_ARGUMENT_TYPE_MISMATCH, diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 5cc592220..ad1922f3b 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -576,7 +576,6 @@ function validateShareableNotRepeatedOnSameDeclaration( } } } - export class FederationMetadata { private _externalTester?: ExternalTester; private _sharingPredicate?: (field: FieldDefinition) => boolean; @@ -790,12 +789,12 @@ export class FederationMetadata { return this.getPost20FederationDirective(FederationDirectiveName.SOURCE_FIELD); } - requireDirective(): Post20FederationDirectiveDefinition { - return this.getPost20FederationDirective(FederationDirectiveName.REQUIRE); + fromContextDirective(): Post20FederationDirectiveDefinition<{ field: string }> { + return this.getPost20FederationDirective(FederationDirectiveName.FROM_CONTEXT); } - setContextDirective(): Post20FederationDirectiveDefinition { - return this.getPost20FederationDirective(FederationDirectiveName.SET_CONTEXT); + contextDirective(): Post20FederationDirectiveDefinition<{ name: string }> { + return this.getPost20FederationDirective(FederationDirectiveName.CONTEXT); } allFederationDirectives(): DirectiveDefinition[] { @@ -851,14 +850,14 @@ export class FederationMetadata { baseDirectives.push(sourceFieldDirective); } - const requireDirective = this.requireDirective(); - if (isFederationDirectiveDefinedInSchema(requireDirective)) { - baseDirectives.push(requireDirective); + const contextDirective = this.contextDirective(); + if (isFederationDirectiveDefinedInSchema(contextDirective)) { + baseDirectives.push(contextDirective); } - const setContextDirective = this.setContextDirective(); - if (isFederationDirectiveDefinedInSchema(setContextDirective)) { - baseDirectives.push(setContextDirective); + const fromContextDirective = this.fromContextDirective(); + if (isFederationDirectiveDefinedInSchema(fromContextDirective)) { + baseDirectives.push(fromContextDirective); } return baseDirectives; @@ -881,6 +880,10 @@ export class FederationMetadata { return this.schema.type(this.federationTypeNameInSchema(FederationTypeName.FIELD_SET)) as ScalarType; } + singleFieldSelectionType(): ScalarType { + return this.schema.type(this.federationTypeNameInSchema(FederationTypeName.FIELD_VALUE)) as ScalarType; + } + allFederationTypes(): NamedType[] { // We manually include the `_Any`, `_Service` and `Entity` types because there are not strictly // speaking part of the federation @link spec. @@ -1102,7 +1105,6 @@ export class FederationBlueprint extends SchemaBlueprint { // validation functions for subgraph schemas by overriding the // validateSubgraphSchema method. validateKnownFeatures(schema, errorCollector); - // If tag is redefined by the user, make sure the definition is compatible with what we expect const tagDirective = metadata.tagDirective(); if (tagDirective) { @@ -1243,7 +1245,7 @@ export function setSchemaAsFed2Subgraph(schema: Schema) { // This is the full @link declaration as added by `asFed2SubgraphDocument`. It's here primarily for uses by tests that print and match // subgraph schema to avoid having to update 20+ tests every time we use a new directive or the order of import changes ... -export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@setContext", "@require"])'; +export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@context", "@fromContext"])'; // This is the full @link declaration that is added when upgrading fed v1 subgraphs to v2 version. It should only be used by tests. export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 687fd99a4..064f4198d 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -24,6 +24,7 @@ export const federationIdentity = 'https://specs.apollo.dev/federation'; export enum FederationTypeName { FIELD_SET = 'FieldSet', + FIELD_VALUE = 'FieldValue', } export enum FederationDirectiveName { @@ -44,8 +45,8 @@ export enum FederationDirectiveName { SOURCE_API = 'sourceAPI', SOURCE_TYPE = 'sourceType', SOURCE_FIELD = 'sourceField', - SET_CONTEXT = 'setContext', - REQUIRE = 'require', + CONTEXT = 'context', + FROM_CONTEXT = 'fromContext', } const fieldSetTypeSpec = createScalarTypeSpecification({ name: FederationTypeName.FIELD_SET }); @@ -89,16 +90,16 @@ const legacyFederationTypes = [ fieldSetTypeSpec, ]; -const setContextSpec = createDirectiveSpecification({ - name: FederationDirectiveName.SET_CONTEXT, - locations: [DirectiveLocation.FIELD_DEFINITION], - args: [{ name: 'name', type: (schema) =>new NonNullType(schema.stringType()) }, { name: 'field', type: (schema) => fieldSetType(schema) }], +const contextSpec = createDirectiveSpecification({ + name: FederationDirectiveName.CONTEXT, + locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT, DirectiveLocation.UNION], + args: [{ name: 'name', type: (schema) =>new NonNullType(schema.stringType()) }], }); -const requireSpec = createDirectiveSpecification({ - name: FederationDirectiveName.REQUIRE, +const fromContextSpec = createDirectiveSpecification({ + name: FederationDirectiveName.FROM_CONTEXT, locations: [DirectiveLocation.ARGUMENT_DEFINITION], - args: [{ name: 'fromContext', type: (schema) =>new NonNullType(schema.stringType()) }, { name: 'field', type: (schema) => fieldSetType(schema) }], + args: [{ name: 'field', type: (schema) => schema.stringType() }], }); const legacyFederationDirectives = [ @@ -122,6 +123,12 @@ function fieldSetType(schema: Schema): InputType { return new NonNullType(metadata.fieldSetType()); } +// function fieldValueType(schema: Schema): InputType { +// const metadata = federationMetadata(schema); +// assert(metadata, `The schema is not a federation subgraph`); +// return new NonNullType(metadata.fieldValueType()); +// } + export class FederationSpecDefinition extends FeatureDefinition { constructor(version: FeatureVersion) { super(new FeatureUrl(federationIdentity, 'federation', version)); @@ -187,8 +194,8 @@ export class FederationSpecDefinition extends FeatureDefinition { if (version.gte(new FeatureVersion(2, 7))) { this.registerSubFeature(SOURCE_VERSIONS.find(new FeatureVersion(0, 1))!); - this.registerDirective(setContextSpec); - this.registerDirective(requireSpec); + this.registerDirective(contextSpec); + this.registerDirective(fromContextSpec); } } } diff --git a/internals-js/src/specs/joinSpec.ts b/internals-js/src/specs/joinSpec.ts index 38be87608..3cf187f23 100644 --- a/internals-js/src/specs/joinSpec.ts +++ b/internals-js/src/specs/joinSpec.ts @@ -37,7 +37,8 @@ export type JoinTypeDirectiveArguments = { key?: string, extension?: boolean, resolvable?: boolean, - isInterfaceObject?: boolean + isInterfaceObject?: boolean, + contexts?: string[], }; export type JoinFieldDirectiveArguments = { @@ -50,10 +51,9 @@ export type JoinFieldDirectiveArguments = { usedOverridden?: boolean, overrideLabel?: string, requiredArguments?: { - position: number, - fromContext: string, name: string, type: string, + context: string, selection: string, }[], } @@ -101,6 +101,7 @@ export class JoinSpecDefinition extends FeatureDefinition { if (this.version.gte(new FeatureVersion(0, 3))) { joinType.addArgument('isInterfaceObject', new NonNullType(schema.booleanType()), false); + joinType.addArgument('contexts', new ListType(new NonNullType(schema.stringType()))); } } @@ -164,10 +165,9 @@ export class JoinSpecDefinition extends FeatureDefinition { // set context const requireType = schema.addType(new InputObjectType('join__RequireArgument')); - requireType.addField('position', new NonNullType(schema.intType())); - requireType.addField('fromContext', new NonNullType(schema.stringType())); requireType.addField('name', new NonNullType(schema.stringType())); requireType.addField('type', new NonNullType(schema.stringType())); + requireType.addField('context', new NonNullType(schema.stringType())); requireType.addField('selection', new NonNullType(joinFieldSet)); joinField.addArgument('requiredArguments', new ListType(new NonNullType(requireType))); From b85d78ae3394bea22a39b317c01a6b727820c94b Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 25 Mar 2024 10:39:21 -0500 Subject: [PATCH 03/82] Finished validation of contexts and making sure extractSubgraphsFromSupergraph works --- .../compose.composeDirective.test.ts.snap | 2 +- .../src/__tests__/compose.setContext.test.ts | 398 +++++++++++++++++- composition-js/src/__tests__/compose.test.ts | 44 +- .../src/__tests__/override.compose.test.ts | 21 +- composition-js/src/merging/merge.ts | 93 ++-- .../__tests__/gateway/lifecycle-hooks.test.ts | 2 +- .../extractSubgraphsFromSupergraph.test.ts | 106 ++++- internals-js/src/error.ts | 2 +- .../src/extractSubgraphsFromSupergraph.ts | 48 ++- internals-js/src/federation.ts | 309 +++++++++++++- internals-js/src/operations.ts | 2 +- internals-js/src/specs/joinSpec.ts | 10 +- 12 files changed, 931 insertions(+), 106 deletions(-) diff --git a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap index 6d651f4ec..14cdba13f 100644 --- a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap +++ b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap @@ -14,7 +14,7 @@ directive @link(url: String, as: String, for: link__Purpose, import: [link__Impo directive @join__graph(name: String!, url: String!) on ENUM_VALUE -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__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 5c8105ce9..71c9f1c8c 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -3,10 +3,10 @@ import { assertCompositionSuccess, composeAsFed2Subgraphs, } from "./testHelper"; -import { printSchema } from '@apollo/federation-internals'; +import { parseSelectionSet, printSchema } from '@apollo/federation-internals'; describe('setContext tests', () => { - test('vanilla setContext works', () => { + test('vanilla setContext - success case', () => { const subgraph1 = { name: 'Subgraph1', utl: 'https://Subgraph1', @@ -45,10 +45,157 @@ describe('setContext tests', () => { }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + console.log(printSchema(result.schema!)); assertCompositionSuccess(result); + }); + + it('setContext with multiple contexts (duck typing) - success', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + foo: Foo! + bar: Bar! + } + + type Foo @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } - const schema = result.schema; - console.log(printSchema(schema)); + type Bar @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it('setContext with multiple contexts (duck typing) - type mismatch', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + foo: Foo! + bar: Bar! + } + + type Foo @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type Bar @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: Int! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: the type of the selection does not match the expected type \"String!\"'); + }); + + it('setContext with multiple contexts (type conditions) - success', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + foo: Foo! + bar: Bar! + } + + type Foo @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type Bar @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop2: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context ... on Foo { prop } ... on Bar { prop2 }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); }); it('context is never set', () => { @@ -92,7 +239,7 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('Context "nocontext" is used in "U.field(a:)" but is never set in any subgraph.'); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"nocontext\" is used at location \"U.field(a:)\" but is never set.'); }); it('resolved field is not available in context', () => { @@ -136,7 +283,7 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: Cannot query field \"invalidprop\" on type \"T\".'); + expect(result.errors?.[0].message).toBe('[Subgraph1] Cannot query field \"invalidprop\" on type \"T\".'); // TODO: Custom error rather than from parseSelectionSet }); it('context variable does not appear in selection', () => { @@ -180,10 +327,247 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('@fromContext argument does not reference a context \"{ prop }\".'); + expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext argument does not reference a context \"{ prop }\".'); + }); + + it('type matches no type conditions', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + bar: Bar! + } + + type Foo @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type Bar @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop2: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context ... on Foo { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: no type condition matches the location \"Bar\"'); }); + + it('setContext on interface - success', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + i: I! + } + + interface I @context(name: "context") { + prop: String! + } + + type T implements I @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it('setContext on interface with type condition - success', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + i: I! + } + + interface I @context(name: "context") { + prop: String! + } + + type T implements I @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context ... on I { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it('type matches multiple type conditions', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + i: I! + } + + interface I @context(name: "context") { + prop: String! + } + + type T implements I @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context ... on I { prop } ... on T { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + it.todo('type condition on union type'); + it.todo('type mismatch in context variable'); it.todo('nullability mismatch is ok if contextual value is non-nullable') it.todo('nullability mismatch is not ok if argument is non-nullable') it.todo('selection contains more than one value'); + + it('trying some stuff', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + const selection = '{ u { id } }'; + const type = result.schema.elementByCoordinate('T'); + const ss = parseSelectionSet({ parentType: type as any, source: selection }); + console.log(ss); + + + }) }) diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index 2ce29d7f1..a96c3a836 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -80,13 +80,13 @@ describe('composition', () => { 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, requiredArguments: [join__RequireArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + 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__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION @@ -99,23 +99,24 @@ describe('composition', () => { V2 @join__enumValue(graph: SUBGRAPH2) } + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + scalar join__DirectiveArguments scalar join__FieldSet + scalar join__FieldValue + enum join__Graph { SUBGRAPH1 @join__graph(name: "Subgraph1", url: "https://Subgraph1") SUBGRAPH2 @join__graph(name: "Subgraph2", url: "https://Subgraph2") } - input join__RequireArgument { - position: Int! - fromContext: String! - name: String! - type: String! - selection: join__FieldSet! - } - scalar link__Import enum link__Purpose { @@ -240,13 +241,13 @@ describe('composition', () => { 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, requiredArguments: [join__RequireArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + 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__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION @@ -259,23 +260,24 @@ describe('composition', () => { V2 @join__enumValue(graph: SUBGRAPH2) } + input join__ContextArgument { + context: String! + name: String! + selection: join__FieldValue! + type: String! + } + scalar join__DirectiveArguments scalar join__FieldSet + scalar join__FieldValue + enum join__Graph { SUBGRAPH1 @join__graph(name: "Subgraph1", url: "https://Subgraph1") SUBGRAPH2 @join__graph(name: "Subgraph2", url: "https://Subgraph2") } - input join__RequireArgument { - fromContext: String! - name: String! - position: Int! - selection: join__FieldSet! - type: String! - } - scalar link__Import enum link__Purpose { @@ -2521,7 +2523,7 @@ describe('composition', () => { 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__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION diff --git a/composition-js/src/__tests__/override.compose.test.ts b/composition-js/src/__tests__/override.compose.test.ts index 8c54f2765..8d13f4ebd 100644 --- a/composition-js/src/__tests__/override.compose.test.ts +++ b/composition-js/src/__tests__/override.compose.test.ts @@ -984,35 +984,36 @@ describe("composition involving @override directive", () => { 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, requiredArguments: [join__RequireArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + 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__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) 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 + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + scalar join__DirectiveArguments scalar join__FieldSet + scalar join__FieldValue + enum join__Graph { SUBGRAPH1 @join__graph(name: \\"Subgraph1\\", url: \\"https://Subgraph1\\") SUBGRAPH2 @join__graph(name: \\"Subgraph2\\", url: \\"https://Subgraph2\\") } - input join__RequireArgument { - position: Int! - fromContext: String! - name: String! - type: String! - selection: join__FieldSet! - } - scalar link__Import enum link__Purpose { diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 78ad473d4..136607f83 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -76,6 +76,7 @@ import { FeatureUrl, isFederationDirectiveDefinedInSchema, parseSelectionSet, + parseContext, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -832,13 +833,13 @@ class Merger { } } - private addUsageToContextMap(context: string, selection: string, argumentDefinition: ArgumentDefinition>) { - if (this.contextToTypeMap.has(context)) { - this.contextToTypeMap.get(context)!.usages.push({ usage: selection, argumentDefinition }); - } else { - this.contextToTypeMap.set(context, { types: new Set(), usages: [{ usage: selection, argumentDefinition }] }); - } - } + // private addUsageToContextMap(context: string, selection: string, argumentDefinition: ArgumentDefinition>) { + // if (this.contextToTypeMap.has(context)) { + // this.contextToTypeMap.get(context)!.usages.push({ usage: selection, argumentDefinition }); + // } else { + // this.contextToTypeMap.set(context, { types: new Set(), usages: [{ usage: selection, argumentDefinition }] }); + // } + // } private addJoinType(sources: (NamedType | undefined)[], dest: NamedType) { const joinTypeDirective = this.joinSpec.typeDirective(this.merged); @@ -1681,40 +1682,37 @@ class Merger { continue; } - const requiredArguments: { - context: string, - name: string, - type: string, - selection: string, - }[] = []; - const metadata = this.subgraphs.values()[idx].metadata(); - const fromContextDirective = metadata.fromContextDirective(); - if (source.kind === 'FieldDefinition' && isFederationDirectiveDefinedInSchema(fromContextDirective)) { - const args = source.arguments(); - args.forEach((arg) => { + const fromContextDirective = this.subgraphs.values()[idx].metadata().fromContextDirective(); + + const contextArguments = (source.kind === 'FieldDefinition' ? source.arguments() : []) + .map((arg): { + context: string, + name: string, + type: string, + selection: string, + } | undefined => { + if (!isFederationDirectiveDefinedInSchema(fromContextDirective)) { + return undefined; + } const appliedDirectives = arg.appliedDirectivesOf(fromContextDirective); - assert(appliedDirectives.length <= 1, () => `@fromContext directive should not be repeatable on ${arg.coordinate}`); - if (appliedDirectives.length === 1) { - const argType = arg.type?.toString(); - assert(argType, () => `Argument ${arg.coordinate} should have a type`); - const { context, selection } = this.parseContext(appliedDirectives[0].arguments().field); - if (!context || !selection) { - this.errors.push(ERRORS.NO_CONTEXT_IN_SELECTION.err( - `@fromContext argument does not reference a context "${appliedDirectives[0].arguments().field}".`, - { nodes: sourceASTs(appliedDirectives[0]) } - )); - } else { - this.addUsageToContextMap(context, selection, arg); - requiredArguments.push({ - name: arg.name, - type: argType, - context, - selection, - }); - } + if (appliedDirectives.length === 0) { + return undefined; } - }); - } + assert(appliedDirectives.length === 1, 'There should be at most one @fromContext directive applied to an argument'); + const directive = appliedDirectives[0]; + const { context, selection } = parseContext(directive.arguments().field); + // these are validated in the subgraph validation phase + assert(context, 'Context should be defined'); + assert(selection, 'Selection should be defined'); + return { + context, + name: arg.name, + type: arg.type!.toString(), + selection, + }; + }) + .filter(isDefined); + const external = this.isExternal(idx, source); const sourceMeta = this.subgraphs.values()[idx].metadata(); const name = this.joinSpecName(idx); @@ -1727,25 +1725,10 @@ class Merger { external: external ? true : undefined, usedOverridden: usedOverridden ? true : undefined, overrideLabel: mergeContext.overrideLabel(idx), - requiredArguments: requiredArguments.length > 0 ? requiredArguments : undefined, + contextArguments: contextArguments.length > 0 ? contextArguments : undefined, }); } } - - private parseContext = (input: string): { context: string | undefined, selection: string | undefined } => { - const regex = /^\$([\w\d_]+)\s*({[\s\S]+})$/; - const match = input.match(regex); - if (!match) { - return { context: undefined, selection: undefined }; - } - - const [, context, selection] = match; - return { - context, - selection, - }; - }; - private getFieldSet(element: SchemaElement, directive: DirectiveDefinition<{fields: string}>): string | undefined { const applications = element.appliedDirectivesOf(directive); assert(applications.length <= 1, () => `Found more than one application of ${directive} on ${element}`); diff --git a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts index b2087fe33..a09f9508e 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -149,7 +149,7 @@ describe('lifecycle hooks', () => { // the supergraph (even just formatting differences), this ID will change // and this test will have to updated. expect(secondCall[0]!.compositionId).toMatchInlineSnapshot( - `"163b5d73231206e3f72d963481b77cdac286cefaa9a49ae4dd371c863059dc94"`, + `"3ea9c25d717cd619d93fbb2592bc5e5bb189fc5ca95b731e6c33ee18a1ea4195"`, ); // second call should have previous info in the second arg expect(secondCall[1]!.compositionId).toEqual(expectedFirstId); diff --git a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts index 68e3d034f..f9383e429 100644 --- a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts +++ b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts @@ -1,4 +1,4 @@ -import { Supergraph, InputObjectType, ObjectType } from ".."; +import { Supergraph, InputObjectType, ObjectType, printSchema } from ".."; test('handles types having no fields referenced by other objects in a subgraph correctly', () => { @@ -815,3 +815,107 @@ test('types that are empty because of overridden fields are erased', () => { const userType = subgraph?.schema.type('User') as ObjectType | undefined; expect(userType).toBeUndefined(); }); + +test('contextual arguments can be extracted', () => { + const supergraph = ` + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + { + query: Query + } + + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + + 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__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + + directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + + directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + + directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION + } + + scalar link__Import + + enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "") + } + + scalar join__FieldSet + + scalar join__DirectiveArguments + + scalar join__FieldValue + + input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! + } + + type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) + { + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) + } + + type T + @join__type(graph: SUBGRAPH1, key: "id", contexts: ["context"]) + { + id: ID! + u: U! + prop: String! + } + + type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") + { + id: ID! + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "context", name: "a", type: "String!", selection: "{ prop }"}]) + } + `; + + const subgraphs = Supergraph.build(supergraph).subgraphs(); + const printedSchema = printSchema(subgraphs.get('Subgraph1')!.schema); + + expect(printedSchema).toMatch(` +type T + @federation__context(name: "context") + @key(fields: "id") +{ + id: ID! + u: U! + prop: String! +}`); + +expect(printedSchema).toMatch(` +type U + @key(fields: "id") +{ + id: ID! @shareable + field(a: String! @federation__fromContext(field: "$context { prop }")): Int! +}`); +}); diff --git a/internals-js/src/error.ts b/internals-js/src/error.ts index 7f3ddf620..76efcb718 100644 --- a/internals-js/src/error.ts +++ b/internals-js/src/error.ts @@ -333,7 +333,7 @@ const CONTEXT_NOT_SET = makeCodeDefinition( const CONTEXT_INVALID_SELECTION= makeCodeDefinition( 'CONTEXT_INVALID_SELECTION', - 'Selection within a context is not valid for contextual type.', + 'Selection within @fromContext must resolve to a single field.', { addedIn: '2.8.0' }, ); diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 0f8b6a39a..fca4952ea 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -40,7 +40,8 @@ import { parseSelectionSet } from "./operations"; import fs from 'fs'; import path from 'path'; import { validateStringContainsBoolean } from "./utils"; -import { errorCauses, printErrors } from "."; +import { errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; +import { Kind, TypeNode, parseType } from 'graphql'; function filteredTypes( supergraph: Schema, @@ -356,7 +357,7 @@ function addEmptyType( assert(typeApplications.length > 0, `Missing @join__type on ${type}`) const subgraphsInfo: SubgraphTypeInfo = new Map(); for (const application of typeApplications) { - const { graph, key, extension, resolvable, isInterfaceObject } = application.arguments(); + const { graph, key, extension, resolvable, isInterfaceObject, contexts } = application.arguments(); let subgraphInfo = subgraphsInfo.get(graph); if (!subgraphInfo) { const subgraph = getSubgraph(application); @@ -371,6 +372,18 @@ function addEmptyType( if (isInterfaceObject) { subgraphType.applyDirective('interfaceObject'); } + if (contexts) { + const contextDirective = subgraph.metadata().contextDirective(); + for (const context of contexts) { + if (!isFederationDirectiveDefinedInSchema(contextDirective)) { + throw new Error(`@context directive is not defined in the subgraph schema: ${subgraph.name}`); + } else { + subgraphType.applyDirective(contextDirective, { + name: context, + }); + } + } + } subgraphInfo = { type: subgraphType, subgraph }; subgraphsInfo.set(graph, subgraphInfo); } @@ -583,6 +596,22 @@ function errorToString(e: any,): string { return causes ? printErrors(causes) : String(e); } +const typeFromTypeNode = (typeNode: TypeNode, schema: Schema): Type => { + if (typeNode.kind === Kind.NON_NULL_TYPE) { + const type = typeFromTypeNode(typeNode.type, schema); + assert(type.kind !== 'NonNullType', 'A non-null type cannot be nested in another non-null type'); + return new NonNullType(type); + } else if (typeNode.kind === Kind.LIST_TYPE) { + return new ListType(typeFromTypeNode(typeNode.type, schema)); + } + + const type = schema.type(typeNode.name.value); + if (!type) { + throw new Error(`Type ${typeNode.name.value} not found in schema`); + } + return type; +}; + function addSubgraphField({ field, type, @@ -610,6 +639,21 @@ function addSubgraphField({ if (joinFieldArgs?.provides) { subgraphField.applyDirective(subgraph.metadata().providesDirective(), {'fields': joinFieldArgs.provides}); } + if (joinFieldArgs?.contextArguments) { + const fromContextDirective = subgraph.metadata().fromContextDirective(); + if (!isFederationDirectiveDefinedInSchema(fromContextDirective)) { + throw new Error(`@context directive is not defined in the subgraph schema: ${subgraph.name}`); + } else { + for (const arg of joinFieldArgs.contextArguments) { + const typeNode = parseType(arg.type); + subgraphField.addArgument(arg.name, typeFromTypeNode(typeNode, subgraph.schema)); + const argOnField = subgraphField.argument(arg.name); + argOnField?.applyDirective(fromContextDirective, { + field: `\$${arg.context} ${arg.selection}`, + }); + } + } + } const external = !!joinFieldArgs?.external; if (external) { subgraphField.applyDirective(subgraph.metadata().externalDirective()); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index ad1922f3b..de63a8e00 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -27,8 +27,13 @@ import { SchemaElement, sourceASTs, UnionType, + ArgumentDefinition, + InputType, + OutputType, + WrapperType, + isWrapperType, } from "./definitions"; -import { assert, MultiMap, printHumanReadableList, OrderedMap, mapValues } from "./utils"; +import { assert, MultiMap, printHumanReadableList, OrderedMap, mapValues, assertUnreachable } from "./utils"; import { SDLValidationRule } from "graphql/validation/ValidationContext"; import { specifiedSDLRules } from "graphql/validation/specifiedRules"; import { @@ -48,7 +53,7 @@ import { } from "graphql"; import { KnownTypeNamesInFederationRule } from "./validation/KnownTypeNamesInFederationRule"; import { buildSchema, buildSchemaFromAST } from "./buildSchema"; -import { parseSelectionSet, SelectionSet } from './operations'; +import { FragmentSelection, parseOperationAST, parseSelectionSet, SelectionSet } from './operations'; import { TAG_VERSIONS } from "./specs/tagSpec"; import { errorCodeDef, @@ -327,6 +332,261 @@ function fieldSetTargetDescription(directive: Directive): st return `${targetKind} "${directive.parent?.coordinate}"`; } +export function parseContext(input: string) { + const regex = /^\$([\w\d_]+)\s*([\s\S]+)$/; + const match = input.match(regex); + if (!match) { + return { context: undefined, selection: undefined }; + } + + const [, context, selection] = match; + return { + context, + selection, + }; +} + +const wrapResolvedType = ({ + originalType, + resolvedType, +}: { + originalType: OutputType, + resolvedType: InputType, +}): InputType | undefined => { + const stack = []; + let unwrappedType: NamedType | WrapperType = originalType; + while(unwrappedType.kind === 'NonNullType' || unwrappedType.kind === 'ListType') { + stack.push(unwrappedType.kind); + unwrappedType = unwrappedType.baseType(); + } + + let type: NamedType | WrapperType = resolvedType; + while(stack.length > 0) { + const kind = stack.pop(); + if (kind === 'NonNullType' && type.kind !== 'NonNullType') { + type = new NonNullType(type); + } else if (kind === 'ListType') { + type = new ListType(type); + } + } + return type; +}; + +const validateFieldValueType = ({ + currentType, + selectionSet, + errorCollector, +}: { + currentType: CompositeType, + selectionSet: SelectionSet, + errorCollector: GraphQLError[], +}): { resolvedType: InputType | undefined } => { + const selections = selectionSet.selections(); + assert(selections.length === 1, 'Expected exactly one field to be selected'); + + const typesArray = selections.map((selection) => { + if (selection.kind !== 'FieldSelection') { + return { resolvedType: undefined }; + } + const { element, selectionSet: childSelectionSet } = selection; + assert(element.definition.type, 'Element type definition should exist'); + const type = element.definition.type; + if (childSelectionSet) { + const { resolvedType } = validateFieldValueType({ + currentType, + selectionSet: childSelectionSet, + errorCollector, + }); + if (!resolvedType) { + return { resolvedType: undefined }; + } + return { resolvedType: wrapResolvedType({ originalType: type, resolvedType}) }; + } + assert(type.kind === 'ScalarType' || type.kind === 'EnumType' || (isWrapperType(type) && type.baseType().kind === 'ScalarType'), + 'Expected a scalar or enum type'); + return { resolvedType: type }; + }); + return typesArray.reduce((acc, { resolvedType }) => { + if (acc.resolvedType?.toString() === resolvedType?.toString()) { + return { resolvedType }; + } + return { resolvedType: undefined }; + }); +}; + +const validateSelectionFormat = ({ + context, + selection, + fromContextParent, + errorCollector, +} : { + context: string, + selection: string, + fromContextParent: ArgumentDefinition>, + errorCollector: GraphQLError[], +}): { + selectionType: 'error' | 'field' | 'inlineFragment', +} => { + // we only need to parse the selection once, not do it for each location + try { + const node = parseOperationAST(selection.trim().startsWith('{') ? selection : `{${selection}}`); + const selections = node.selectionSet.selections; + if (selections.length === 0) { + // a selection must be made. + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no selection is made`, + { nodes: sourceASTs(fromContextParent) } + )); + return { selectionType: 'error' }; + } + const firstSelectionKind = selections[0].kind; + if (firstSelectionKind === 'Field') { + // if the first selection is a field, there should be only one + if (selections.length !== 1) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple selections are made`, + { nodes: sourceASTs(fromContextParent) } + )); + return { selectionType: 'error' }; + } + return { selectionType: 'field' }; + } else if (firstSelectionKind === 'InlineFragment') { + const inlineFragmentTypeConditionMap: Map = new Map(); + if (!selections.every((s) => s.kind === 'InlineFragment')) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple fields could be selected`, + { nodes: sourceASTs(fromContextParent) } + )); + return { selectionType: 'error' }; + } + selections.forEach((s) => { + assert(s.kind === 'InlineFragment', 'Expected an inline fragment'); + const { typeCondition }= s; + if (typeCondition) { + inlineFragmentTypeConditionMap.set(typeCondition.name.value, false); + } + }); + if (inlineFragmentTypeConditionMap.size !== selections.length) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type conditions have same name`, + { nodes: sourceASTs(fromContextParent) } + )); + return { selectionType: 'error' }; + } + return { selectionType: 'inlineFragment' }; + } else if (firstSelectionKind === 'FragmentSpread') { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: fragment spread is not allowed`, + { nodes: sourceASTs(fromContextParent) } + )); + return { selectionType: 'error' }; + } else { + assertUnreachable(firstSelectionKind); + } + } catch (err) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: ${err.message}`, + { nodes: sourceASTs(fromContextParent) } + )); + + return { selectionType: 'error' }; + } +} + +function validateFieldValue({ + context, + selection, + fromContextParent, + setContextLocations, + errorCollector, +} : { + context: string, + selection: string, + fromContextParent: ArgumentDefinition>, + setContextLocations: (ObjectType | InterfaceType | UnionType)[], + errorCollector: GraphQLError[], +}): void { + const expectedType = fromContextParent.type; + const { + selectionType, + } = validateSelectionFormat({ context, selection, fromContextParent, errorCollector }); + + // if there was an error, just return, we've already added it to the errorCollector + if (selectionType === 'error') { + return; + } + + for (const location of setContextLocations) { + // for each location, we need to validate that the selection will result in exactly one field being selected + // the number of selection sets created will be the same + const selectionSet = parseSelectionSet({ parentType: location, source: selection}); + if (selectionType === 'field') { + const { resolvedType } = validateFieldValueType({ + currentType: location, + selectionSet, + errorCollector, + }); + if (resolvedType === undefined || resolvedType.toString() !== expectedType?.toString()) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } + } else if (selectionType === 'inlineFragment') { + // ensure that each location maps to exactly one fragment + const types = selectionSet.selections() + .filter((s): s is FragmentSelection => s.kind === 'FragmentSelection') + .filter(s => { + const { typeCondition } = s.element; + assert(typeCondition, 'Expected a type condition on FragmentSelection'); + if (typeCondition.kind === 'ObjectType') { + return location.name === typeCondition.name; + } else if (typeCondition.kind === 'InterfaceType') { + return location.kind === 'InterfaceType' ? location.name === typeCondition.name : typeCondition.isPossibleRuntimeType(location); + } else if (typeCondition.kind === 'UnionType') { + if (location.kind === 'InterfaceType') { + return false; + } else if (location.kind === 'UnionType') { + return typeCondition.name === location.name; + } else { + return typeCondition.types().includes(location); + } + } else { + assertUnreachable(typeCondition); + } + }); + + if (types.length === 0) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no type condition matches the location "${location.coordinate}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } else if (types.length > 1) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple type conditions match the location "${location.coordinate}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } else { + const { resolvedType } = validateFieldValueType({ + currentType: location, + selectionSet: types[0].selectionSet, + errorCollector, + }); + if (resolvedType === undefined || resolvedType.toString() !== expectedType?.toString()) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } + } + } + } +} + function validateAllFieldSet>({ definition, targetTypeExtractor, @@ -1096,6 +1356,51 @@ export class FederationBlueprint extends SchemaBlueprint { metadata, }); + // validate @context and @fromContext + const contextDirective = metadata.contextDirective(); + const contextToTypeMap = new Map(); + for (const application of contextDirective.applications()) { + const parent = application.parent; + const name = application.arguments().name as string; + const types = contextToTypeMap.get(name); + if (types) { + types.push(parent); + } else { + contextToTypeMap.set(name, [parent]); + } + } + + const fromContextDirective = metadata.fromContextDirective(); + if (isFederationDirectiveDefinedInSchema(fromContextDirective)) { + for (const application of fromContextDirective.applications()) { + const { field } = application.arguments(); + const { context, selection } = parseContext(field); + const parent = application.parent as ArgumentDefinition>; + if (!context || !selection) { + errorCollector.push(ERRORS.NO_CONTEXT_IN_SELECTION.err( + `@fromContext argument does not reference a context "${field}".`, + { nodes: sourceASTs(application) } + )); + } else { + const locations = contextToTypeMap.get(context); + if (!locations) { + errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( + `Context "${context}" is used at location "${parent.coordinate}" but is never set.`, + { nodes: sourceASTs(application) } + )); + } else { + validateFieldValue({ + context, + selection, + fromContextParent: parent, + setContextLocations: locations, + errorCollector, + }); + } + } + } + } + validateNoExternalOnInterfaceFields(metadata, errorCollector); validateAllExternalFieldsUsed(metadata, errorCollector); validateKeyOnInterfacesAreAlsoOnAllImplementations(metadata, errorCollector); diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index c98573a06..58405b3f7 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -3874,7 +3874,7 @@ export function parseSelectionSet({ return selectionSet; } -function parseOperationAST(source: string): OperationDefinitionNode { +export function parseOperationAST(source: string): OperationDefinitionNode { const parsed = parse(source); validate(parsed.definitions.length === 1, () => 'Selections should contain a single definitions, found ' + parsed.definitions.length); const def = parsed.definitions[0]; diff --git a/internals-js/src/specs/joinSpec.ts b/internals-js/src/specs/joinSpec.ts index 3cf187f23..82e156d9c 100644 --- a/internals-js/src/specs/joinSpec.ts +++ b/internals-js/src/specs/joinSpec.ts @@ -50,7 +50,7 @@ export type JoinFieldDirectiveArguments = { external?: boolean, usedOverridden?: boolean, overrideLabel?: string, - requiredArguments?: { + contextArguments?: { name: string, type: string, context: string, @@ -163,14 +163,16 @@ export class JoinSpecDefinition extends FeatureDefinition { // progressive override joinField.addArgument('overrideLabel', schema.stringType()); + const fieldValue = this.addScalarType(schema, 'FieldValue'); + // set context - const requireType = schema.addType(new InputObjectType('join__RequireArgument')); + const requireType = schema.addType(new InputObjectType('join__ContextArgument')); requireType.addField('name', new NonNullType(schema.stringType())); requireType.addField('type', new NonNullType(schema.stringType())); requireType.addField('context', new NonNullType(schema.stringType())); - requireType.addField('selection', new NonNullType(joinFieldSet)); + requireType.addField('selection', new NonNullType(fieldValue)); - joinField.addArgument('requiredArguments', new ListType(new NonNullType(requireType))); + joinField.addArgument('contextArguments', new ListType(new NonNullType(requireType))); } if (this.isV01()) { From d5117f3d471f7d970887ff05c7fd9261a2415c70 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 26 Mar 2024 18:46:34 -0500 Subject: [PATCH 04/82] Bump join spec and federation spec --- .../compose.composeDirective.test.ts.snap | 2 +- composition-js/src/__tests__/compose.test.ts | 10 +- .../src/__tests__/override.compose.test.ts | 4 +- composition-js/src/composeDirectiveManager.ts | 1 + composition-js/src/merging/merge.ts | 42 ++---- .../__tests__/gateway/lifecycle-hooks.test.ts | 2 +- .../extractSubgraphsFromSupergraph.test.ts | 128 ++++++++++-------- .../src/directiveAndTypeSpecification.ts | 9 +- .../src/extractSubgraphsFromSupergraph.ts | 37 +++-- internals-js/src/federation.ts | 4 +- internals-js/src/index.ts | 1 + internals-js/src/specs/contextSpec.ts | 72 ++++++++++ internals-js/src/specs/federationSpec.ts | 22 +-- internals-js/src/specs/joinSpec.ts | 10 +- internals-js/src/supergraphs.ts | 1 + 15 files changed, 208 insertions(+), 137 deletions(-) create mode 100644 internals-js/src/specs/contextSpec.ts diff --git a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap index 14cdba13f..6d651f4ec 100644 --- a/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap +++ b/composition-js/src/__tests__/__snapshots__/compose.composeDirective.test.ts.snap @@ -14,7 +14,7 @@ directive @link(url: String, as: String, for: link__Purpose, import: [link__Impo directive @join__graph(name: String!, url: String!) on ENUM_VALUE -directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +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__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION diff --git a/composition-js/src/__tests__/compose.test.ts b/composition-js/src/__tests__/compose.test.ts index f5a5c6f32..12ed3f760 100644 --- a/composition-js/src/__tests__/compose.test.ts +++ b/composition-js/src/__tests__/compose.test.ts @@ -70,7 +70,7 @@ describe('composition', () => { expect(result.supergraphSdl).toMatchString(` schema @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { query: Query } @@ -85,7 +85,7 @@ describe('composition', () => { 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, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + 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 @@ -231,7 +231,7 @@ describe('composition', () => { expect(result.supergraphSdl).toMatchString(` schema @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) { query: Query } @@ -246,7 +246,7 @@ describe('composition', () => { 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, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + 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 @@ -2522,7 +2522,7 @@ describe('composition', () => { 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, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + 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 diff --git a/composition-js/src/__tests__/override.compose.test.ts b/composition-js/src/__tests__/override.compose.test.ts index 9ef3935fa..6f53f20a2 100644 --- a/composition-js/src/__tests__/override.compose.test.ts +++ b/composition-js/src/__tests__/override.compose.test.ts @@ -974,7 +974,7 @@ describe("composition involving @override directive", () => { expect(result.supergraphSdl).toMatchInlineSnapshot(` "schema @link(url: \\"https://specs.apollo.dev/link/v1.0\\") - @link(url: \\"https://specs.apollo.dev/join/v0.4\\", for: EXECUTION) + @link(url: \\"https://specs.apollo.dev/join/v0.5\\", for: EXECUTION) { query: Query } @@ -989,7 +989,7 @@ describe("composition involving @override directive", () => { 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, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + 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 diff --git a/composition-js/src/composeDirectiveManager.ts b/composition-js/src/composeDirectiveManager.ts index ba48a80ae..816e25354 100644 --- a/composition-js/src/composeDirectiveManager.ts +++ b/composition-js/src/composeDirectiveManager.ts @@ -176,6 +176,7 @@ export class ComposeDirectiveManager { sg.metadata().authenticatedDirective(), sg.metadata().requiresScopesDirective(), sg.metadata().policyDirective(), + sg.metadata().contextDirective(), ].map(d => d.name); if (directivesComposedByDefault.includes(directive.name)) { this.pushHint(new CompositionHint( diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index f99abcd8b..e26882d53 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -79,6 +79,7 @@ import { parseContext, CoreFeature, Subgraph, + StaticArgumentsTransform, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -294,7 +295,7 @@ class Merger { readonly merged: Schema = new Schema(); readonly subgraphNamesToJoinSpecName: Map; readonly mergedFederationDirectiveNames = new Set(); - readonly mergedFederationDirectiveInSupergraph = new Map(); + readonly mergedFederationDirectiveInSupergraph = new Map(); readonly enumUsages = new Map(); private composeDirectiveManager: ComposeDirectiveManager; private mismatchReporter: MismatchReporter; @@ -459,6 +460,7 @@ class Merger { this.mergedFederationDirectiveInSupergraph.set(specInSupergraph.url.name, { definition: this.merged.directive(nameInSupergraph)!, argumentsMerger, + staticArgumentTransform: compositionSpec.staticArgumentTransform, }); } } @@ -861,25 +863,6 @@ class Merger { } } - private addTypeToContextMap(type: CompositeType, contexts: string[]) { - for (const context of contexts) { - if (this.contextToTypeMap.has(context)) { - this.contextToTypeMap.get(context)!.types.add(type); - } else { - this.contextToTypeMap.set(context, { types: new Set([type]), usages: [] }); - } - - } - } - - // private addUsageToContextMap(context: string, selection: string, argumentDefinition: ArgumentDefinition>) { - // if (this.contextToTypeMap.has(context)) { - // this.contextToTypeMap.get(context)!.usages.push({ usage: selection, argumentDefinition }); - // } else { - // this.contextToTypeMap.set(context, { types: new Set(), usages: [{ usage: selection, argumentDefinition }] }); - // } - // } - private addJoinType(sources: (NamedType | undefined)[], dest: NamedType) { const joinTypeDirective = this.joinSpec.typeDirective(this.merged); for (const [idx, source] of sources.entries()) { @@ -895,15 +878,6 @@ class Merger { const isInterfaceObject = sourceMetadata.isInterfaceObjectType(source) ? true : undefined; const keys = source.appliedDirectivesOf(sourceMetadata.keyDirective()); const name = this.joinSpecName(idx); - const contextDirective = sourceMetadata.contextDirective(); - let contexts: string[] | undefined = undefined; - if (isFederationDirectiveDefinedInSchema(contextDirective) && isCompositeType(dest)) { - const appliedDirectives = source.appliedDirectivesOf(contextDirective); - if (appliedDirectives.length > 0) { - contexts = appliedDirectives.map(d => d.arguments().name); - this.addTypeToContextMap(dest, contexts); - } - } if (!keys.length) { dest.applyDirective(joinTypeDirective, { graph: name, isInterfaceObject }); @@ -911,7 +885,7 @@ class Merger { for (const key of keys) { const extension = key.ofExtension() || source.hasAppliedDirective(sourceMetadata.extendsDirective()) ? true : undefined; const { resolvable } = key.arguments(); - dest.applyDirective(joinTypeDirective, { graph: name, key: key.arguments().fields, extension, resolvable, isInterfaceObject, contexts }); + dest.applyDirective(joinTypeDirective, { graph: name, key: key.arguments().fields, extension, resolvable, isInterfaceObject }); } } } @@ -1765,7 +1739,7 @@ class Merger { assert(context, 'Context should be defined'); assert(selection, 'Selection should be defined'); return { - context, + context: `${this.subgraphs.values()[idx].name}__${context}`, name: arg.name, type: arg.type!.toString(), selection, @@ -2677,11 +2651,15 @@ class Merger { return; } + const directiveInSupergraph = this.mergedFederationDirectiveInSupergraph.get(name); + if (dest.schema().directive(name)?.repeatable) { // For repeatable directives, we simply include each application found but with exact duplicates removed while (perSource.length > 0) { const directive = this.pickNextDirective(perSource); - dest.applyDirective(directive.name, directive.arguments(false)); + + const transformedArgs = directiveInSupergraph && directiveInSupergraph.staticArgumentTransform && directiveInSupergraph.staticArgumentTransform(this.subgraphs.values()[0], directive.arguments(false)); + dest.applyDirective(directive.name, transformedArgs ?? directive.arguments(false)); // We remove every instances of this particular application. That is we remove any other applicaiton with // the same arguments. Note that when doing so, we include default values. This allows "merging" 2 applications // when one rely on the default value while another don't but explicitely uses that exact default value. diff --git a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts index a09f9508e..43b65e309 100644 --- a/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts +++ b/gateway-js/src/__tests__/gateway/lifecycle-hooks.test.ts @@ -149,7 +149,7 @@ describe('lifecycle hooks', () => { // the supergraph (even just formatting differences), this ID will change // and this test will have to updated. expect(secondCall[0]!.compositionId).toMatchInlineSnapshot( - `"3ea9c25d717cd619d93fbb2592bc5e5bb189fc5ca95b731e6c33ee18a1ea4195"`, + `"6dc1bde2b9818fabec62208c5d8825abaa1bae89635fa6f3a5ffea7b78fc6d82"`, ); // second call should have previous info in the second arg expect(secondCall[1]!.compositionId).toEqual(expectedFirstId); diff --git a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts index f9383e429..40e95f500 100644 --- a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts +++ b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts @@ -818,84 +818,92 @@ test('types that are empty because of overridden fields are erased', () => { test('contextual arguments can be extracted', () => { const supergraph = ` - schema - @link(url: "https://specs.apollo.dev/link/v1.0") - @link(url: "https://specs.apollo.dev/join/v0.4", for: EXECUTION) - { - query: Query - } + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/context/v0.1") +{ + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE - directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA +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__graph(name: String!, url: String!) 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__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false, contexts: [String!]) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE - 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__unionMember(graph: join__Graph!, member: String!) repeatable on UNION - directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE - directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION +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 @context(name: String!) repeatable on INTERFACE | OBJECT | UNION - directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION +directive @context__fromContext(field: String) on ARGUMENT_DEFINITION - enum link__Purpose { - """ - \`SECURITY\` features provide metadata necessary to securely resolve fields. - """ - SECURITY +enum link__Purpose { + """ + \`SECURITY\` features provide metadata necessary to securely resolve fields. + """ + SECURITY - """ - \`EXECUTION\` features provide metadata necessary for operation execution. - """ - EXECUTION - } + """ + \`EXECUTION\` features provide metadata necessary for operation execution. + """ + EXECUTION +} - scalar link__Import +scalar link__Import - enum join__Graph { - SUBGRAPH1 @join__graph(name: "Subgraph1", url: "") - SUBGRAPH2 @join__graph(name: "Subgraph2", url: "") - } +enum join__Graph { + SUBGRAPH1 @join__graph(name: "Subgraph1", url: "") + SUBGRAPH2 @join__graph(name: "Subgraph2", url: "") +} - scalar join__FieldSet +scalar join__FieldSet - scalar join__DirectiveArguments +scalar join__DirectiveArguments - scalar join__FieldValue +scalar join__FieldValue - input join__ContextArgument { - name: String! - type: String! - context: String! - selection: join__FieldValue! - } +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} - type Query - @join__type(graph: SUBGRAPH1) - @join__type(graph: SUBGRAPH2) - { - t: T! @join__field(graph: SUBGRAPH1) - a: Int! @join__field(graph: SUBGRAPH2) - } +scalar context__context - type T - @join__type(graph: SUBGRAPH1, key: "id", contexts: ["context"]) - { - id: ID! - u: U! - prop: String! - } +type Query + @join__type(graph: SUBGRAPH1) + @join__type(graph: SUBGRAPH2) +{ + t: T! @join__field(graph: SUBGRAPH1) + a: Int! @join__field(graph: SUBGRAPH2) +} + +type T + @join__type(graph: SUBGRAPH1, key: "id") + @context(name: "Subgraph1__context") +{ + id: ID! + u: U! + prop: String! +} - type U - @join__type(graph: SUBGRAPH1, key: "id") - @join__type(graph: SUBGRAPH2, key: "id") - { - id: ID! - field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "context", name: "a", type: "String!", selection: "{ prop }"}]) - } +type U + @join__type(graph: SUBGRAPH1, key: "id") + @join__type(graph: SUBGRAPH2, key: "id") +{ + id: ID! + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String!", selection: "{ prop }"}]) +} `; const subgraphs = Supergraph.build(supergraph).subgraphs(); @@ -903,8 +911,8 @@ test('contextual arguments can be extracted', () => { expect(printedSchema).toMatch(` type T - @federation__context(name: "context") @key(fields: "id") + @federation__context(name: "context") { id: ID! u: U! diff --git a/internals-js/src/directiveAndTypeSpecification.ts b/internals-js/src/directiveAndTypeSpecification.ts index 62d769ae5..57b728dcc 100644 --- a/internals-js/src/directiveAndTypeSpecification.ts +++ b/internals-js/src/directiveAndTypeSpecification.ts @@ -24,6 +24,7 @@ import { sameType } from "./types"; import { arrayEquals, assert } from "./utils"; import { ArgumentCompositionStrategy } from "./argumentCompositionStrategies"; import { FeatureDefinition, FeatureVersion } from "./specs/coreSpec"; +import { Subgraph } from '.'; export type DirectiveSpecification = { name: string, @@ -34,8 +35,11 @@ export type DirectiveSpecification = { export type DirectiveCompositionSpecification = { supergraphSpecification: (federationVersion: FeatureVersion) => FeatureDefinition, argumentsMerger?: (schema: Schema, feature: CoreFeature) => ArgumentMerger | GraphQLError, + staticArgumentTransform?: StaticArgumentsTransform, } +export type StaticArgumentsTransform = (subgraph: Subgraph, args: Readonly<{[key: string]: any}>) => Readonly<{[key: string]: any}>; + export type ArgumentMerger = { merge: (argName: string, values: any[]) => any, toString: () => string, @@ -75,6 +79,7 @@ export function createDirectiveSpecification({ args = [], composes = false, supergraphSpecification = undefined, + staticArgumentTransform = undefined, }: { name: string, locations: DirectiveLocation[], @@ -82,6 +87,7 @@ export function createDirectiveSpecification({ args?: DirectiveArgumentSpecification[], composes?: boolean, supergraphSpecification?: (fedVersion: FeatureVersion) => FeatureDefinition, + staticArgumentTransform?: (subgraph: Subgraph, args: {[key: string]: any}) => {[key: string]: any}, }): DirectiveSpecification { let composition: DirectiveCompositionSpecification | undefined = undefined; if (composes) { @@ -109,7 +115,7 @@ export function createDirectiveSpecification({ + `${strategy.name} only supports ${supportedMsg}` ); } - }; + } return { merge: (argName, values) => { const strategy = argStrategies.get(argName); @@ -128,6 +134,7 @@ export function createDirectiveSpecification({ composition = { supergraphSpecification, argumentsMerger, + staticArgumentTransform, }; } diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index fca4952ea..6ab606cc1 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -357,7 +357,7 @@ function addEmptyType( assert(typeApplications.length > 0, `Missing @join__type on ${type}`) const subgraphsInfo: SubgraphTypeInfo = new Map(); for (const application of typeApplications) { - const { graph, key, extension, resolvable, isInterfaceObject, contexts } = application.arguments(); + const { graph, key, extension, resolvable, isInterfaceObject } = application.arguments(); let subgraphInfo = subgraphsInfo.get(graph); if (!subgraphInfo) { const subgraph = getSubgraph(application); @@ -372,18 +372,6 @@ function addEmptyType( if (isInterfaceObject) { subgraphType.applyDirective('interfaceObject'); } - if (contexts) { - const contextDirective = subgraph.metadata().contextDirective(); - for (const context of contexts) { - if (!isFederationDirectiveDefinedInSchema(contextDirective)) { - throw new Error(`@context directive is not defined in the subgraph schema: ${subgraph.name}`); - } else { - subgraphType.applyDirective(contextDirective, { - name: context, - }); - } - } - } subgraphInfo = { type: subgraphType, subgraph }; subgraphsInfo.set(graph, subgraphInfo); } @@ -395,6 +383,21 @@ function addEmptyType( } } } + + const contextApplications = type.appliedDirectivesOf('context'); // TODO: is there a better way to get this? + // for every application, apply the context directive to the correct subgraph + for (const application of contextApplications) { + const { name } = application.arguments(); + const match = name.match(/(.*?)__([\s\S]*)/); + const graph = match ? match[1] : undefined; + const context = match ? match[2] : undefined; + + const subgraphInfo = subgraphsInfo.get(graph ? graph.toUpperCase() : undefined); + const contextDirective = subgraphInfo?.subgraph.metadata().contextDirective(); + if (subgraphInfo && contextDirective && isFederationDirectiveDefinedInSchema(contextDirective)) { + subgraphInfo.type.applyDirective(contextDirective, {name: context}); + } + } return subgraphsInfo; } @@ -645,11 +648,17 @@ function addSubgraphField({ throw new Error(`@context directive is not defined in the subgraph schema: ${subgraph.name}`); } else { for (const arg of joinFieldArgs.contextArguments) { + // this code will remove the subgraph name from the context + const match = arg.context.match(/.*?__([\s\S]*)/); + if (!match) { + throw new Error(`Invalid context argument: ${arg.context}`); + } + const typeNode = parseType(arg.type); subgraphField.addArgument(arg.name, typeFromTypeNode(typeNode, subgraph.schema)); const argOnField = subgraphField.argument(arg.name); argOnField?.applyDirective(fromContextDirective, { - field: `\$${arg.context} ${arg.selection}`, + field: `\$${match[1]} ${arg.selection}`, }); } } diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 35dd658b8..8c2c8cb04 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1564,9 +1564,9 @@ export function setSchemaAsFed2Subgraph(schema: Schema, useLatest: boolean = fal // This is the full @link declaration as added by `asFed2SubgraphDocument`. It's here primarily for uses by tests that print and match // subgraph schema to avoid having to update 20+ tests every time we use a new directive or the order of import changes ... -export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@context", "@fromContext"])'; +export const FEDERATION2_LINK_WITH_FULL_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject", "@authenticated", "@requiresScopes", "@policy", "@sourceAPI", "@sourceType", "@sourceField", "@context", "@fromContext"])'; // This is the full @link declaration that is added when upgrading fed v1 subgraphs to v2 version. It should only be used by tests. -export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; +export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS = '@link(url: "https://specs.apollo.dev/federation/v2.8", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; // This is the federation @link for tests that go through the SchemaUpgrader. export const FEDERATION2_LINK_WITH_AUTO_EXPANDED_IMPORTS_UPGRADED = '@link(url: "https://specs.apollo.dev/federation/v2.4", import: ["@key", "@requires", "@provides", "@external", "@tag", "@extends", "@shareable", "@inaccessible", "@override", "@composeDirective", "@interfaceObject"])'; diff --git a/internals-js/src/index.ts b/internals-js/src/index.ts index 78d0d9950..3898ccfd8 100644 --- a/internals-js/src/index.ts +++ b/internals-js/src/index.ts @@ -12,6 +12,7 @@ export * from './specs/joinSpec'; export * from './specs/tagSpec'; export * from './specs/inaccessibleSpec'; export * from './specs/federationSpec'; +export * from './specs/contextSpec'; export * from './supergraphs'; export * from './error'; export * from './schemaUpgrader'; diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts new file mode 100644 index 000000000..f6f649c51 --- /dev/null +++ b/internals-js/src/specs/contextSpec.ts @@ -0,0 +1,72 @@ +import { DirectiveLocation } from "graphql"; +import { + CorePurpose, + FeatureDefinition, + FeatureDefinitions, + FeatureUrl, + FeatureVersion, +} from "./coreSpec"; +import { NonNullType } from "../definitions"; +import { DirectiveSpecification, createDirectiveSpecification, createScalarTypeSpecification } from "../directiveAndTypeSpecification"; +import { registerKnownFeature } from "../knownCoreFeatures"; +import { Subgraph } from '../federation'; + +export enum ContextDirectiveName { + CONTEXT = 'context', + FROM_CONTEXT = 'fromContext', +} +export class ContextSpecDefinition extends FeatureDefinition { + public static readonly directiveName = 'context'; + public static readonly identity = + `https://specs.apollo.dev/${ContextSpecDefinition.directiveName}`; + public readonly contextDirectiveSpec: DirectiveSpecification; + public readonly fromContextDirectiveSpec: DirectiveSpecification; + + constructor(version: FeatureVersion) { + super( + new FeatureUrl( + ContextSpecDefinition.identity, + ContextSpecDefinition.directiveName, + version, + ) + ); + + this.registerType(createScalarTypeSpecification({ name: ContextDirectiveName.CONTEXT })); + + this.contextDirectiveSpec = createDirectiveSpecification({ + name: ContextDirectiveName.CONTEXT, + locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT, DirectiveLocation.UNION], + args: [{ name: 'name', type: (schema) =>new NonNullType(schema.stringType()) }], + composes: true, + repeatable: true, + supergraphSpecification: (fedVersion) => CONTEXT_VERSIONS.getMinimumRequiredVersion(fedVersion), + staticArgumentTransform: (subgraph: Subgraph, args: {[key: string]: any}) => { + const subgraphName = subgraph.name; + return { + name: `${subgraphName}__${args.name}`, + }; + }, + }); + + this.fromContextDirectiveSpec = createDirectiveSpecification({ + name: ContextDirectiveName.FROM_CONTEXT, + locations: [DirectiveLocation.ARGUMENT_DEFINITION], + args: [{ name: 'field', type: (schema) => schema.stringType() }], + composes: false, + }); + + this.registerDirective(this.contextDirectiveSpec); + this.registerDirective(this.fromContextDirectiveSpec); + } + + get defaultCorePurpose(): CorePurpose { + return 'SECURITY'; + } +} + +export const CONTEXT_VERSIONS = + new FeatureDefinitions( + ContextSpecDefinition.identity + ).add(new ContextSpecDefinition(new FeatureVersion(0, 1))); + +registerKnownFeature(CONTEXT_VERSIONS); diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 064f4198d..062c73eb1 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -19,6 +19,7 @@ import { AUTHENTICATED_VERSIONS } from "./authenticatedSpec"; import { REQUIRES_SCOPES_VERSIONS } from "./requiresScopesSpec"; import { POLICY_VERSIONS } from './policySpec'; import { SOURCE_VERSIONS } from './sourceSpec'; +import { CONTEXT_VERSIONS } from './contextSpec'; export const federationIdentity = 'https://specs.apollo.dev/federation'; @@ -90,18 +91,6 @@ const legacyFederationTypes = [ fieldSetTypeSpec, ]; -const contextSpec = createDirectiveSpecification({ - name: FederationDirectiveName.CONTEXT, - locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT, DirectiveLocation.UNION], - args: [{ name: 'name', type: (schema) =>new NonNullType(schema.stringType()) }], -}); - -const fromContextSpec = createDirectiveSpecification({ - name: FederationDirectiveName.FROM_CONTEXT, - locations: [DirectiveLocation.ARGUMENT_DEFINITION], - args: [{ name: 'field', type: (schema) => schema.stringType() }], -}); - const legacyFederationDirectives = [ keyDirectiveSpec, requiresDirectiveSpec, @@ -194,8 +183,10 @@ export class FederationSpecDefinition extends FeatureDefinition { if (version.gte(new FeatureVersion(2, 7))) { this.registerSubFeature(SOURCE_VERSIONS.find(new FeatureVersion(0, 1))!); - this.registerDirective(contextSpec); - this.registerDirective(fromContextSpec); + } + + if (version.gte(new FeatureVersion(2, 8))) { + this.registerSubFeature(CONTEXT_VERSIONS.find(new FeatureVersion(0, 1))!); } } } @@ -208,6 +199,7 @@ export const FEDERATION_VERSIONS = new FeatureDefinitions(joinIdentity) .add(new JoinSpecDefinition(new FeatureVersion(0, 1))) .add(new JoinSpecDefinition(new FeatureVersion(0, 2))) .add(new JoinSpecDefinition(new FeatureVersion(0, 3), new FeatureVersion(2, 0))) - .add(new JoinSpecDefinition(new FeatureVersion(0, 4), new FeatureVersion(2, 7))); + .add(new JoinSpecDefinition(new FeatureVersion(0, 4), new FeatureVersion(2, 7))) + .add(new JoinSpecDefinition(new FeatureVersion(0, 5), new FeatureVersion(2, 8))); registerKnownFeature(JOIN_VERSIONS); diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index ee6fbeadb..55b7a58da 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -14,6 +14,7 @@ export const DEFAULT_SUPPORTED_SUPERGRAPH_FEATURES = new Set([ 'https://specs.apollo.dev/join/v0.2', 'https://specs.apollo.dev/join/v0.3', 'https://specs.apollo.dev/join/v0.4', + 'https://specs.apollo.dev/join/v0.5', 'https://specs.apollo.dev/tag/v0.1', 'https://specs.apollo.dev/tag/v0.2', 'https://specs.apollo.dev/tag/v0.3', From 672b705c12db6ac5a726336e18f52ddf9ffc5501 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 12 Apr 2024 09:49:21 -0500 Subject: [PATCH 05/82] checkpoint more updates --- .../src/__tests__/compose.setContext.test.ts | 383 +++++++++++++++++- internals-js/src/error.ts | 7 + internals-js/src/federation.ts | 228 ++++++++--- internals-js/src/operations.ts | 7 +- internals-js/src/supergraphs.ts | 1 + internals-js/src/utils.ts | 10 + query-graphs-js/src/conditionsCaching.ts | 12 +- query-graphs-js/src/conditionsValidation.ts | 5 +- query-graphs-js/src/graphPath.ts | 152 ++++++- query-graphs-js/src/pathTree.ts | 56 ++- query-graphs-js/src/querygraph.ts | 100 ++++- query-planner-js/src/QueryPlan.ts | 1 + .../src/__tests__/buildPlan.test.ts | 320 +++++++++++++++ query-planner-js/src/buildPlan.ts | 111 ++++- 14 files changed, 1272 insertions(+), 121 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 71c9f1c8c..b4f841172 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -3,7 +3,6 @@ import { assertCompositionSuccess, composeAsFed2Subgraphs, } from "./testHelper"; -import { parseSelectionSet, printSchema } from '@apollo/federation-internals'; describe('setContext tests', () => { test('vanilla setContext - success case', () => { @@ -45,9 +44,92 @@ describe('setContext tests', () => { }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); - console.log(printSchema(result.schema!)); assertCompositionSuccess(result); }); + + test('using a list as input to @fromContext', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: [String]! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: [String]! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + // test('vanilla setContext - success case alt', () => { + // const subgraph1 = { + // name: 'Subgraph1', + // utl: 'https://Subgraph1', + // typeDefs: gql` + // type Query { + // t: T! + // } + + // type T @key(fields: "id") @context(name: "context") { + // id: ID! + // u: U! + // prop: String! + // } + + // type U @key(fields: "id") { + // id: ID! + // field : Int! @requires(fields: "field2") + // field2: String! @external + // } + // ` + // }; + + // const subgraph2 = { + // name: 'Subgraph2', + // utl: 'https://Subgraph2', + // typeDefs: gql` + // type Query { + // a: Int! + // } + + // type U @key(fields: "id") { + // id: ID! + // field2: String! + // } + // ` + // }; + + // const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + // assertCompositionSuccess(result); + // }); it('setContext with multiple contexts (duck typing) - success', () => { const subgraph1 = { @@ -283,7 +365,7 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Cannot query field \"invalidprop\" on type \"T\".'); // TODO: Custom error rather than from parseSelectionSet + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid for type T. Error: Cannot query field \"invalidprop\" on type \"T\".'); }); it('context variable does not appear in selection', () => { @@ -517,14 +599,198 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - it.todo('type condition on union type'); + + it('type condition on union type', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + union T @context(name: "context") = T1 | T2 + + type T1 @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + a: String! + } + + type T2 @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + b: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it('type condition on union, but a member of the union doesnt have property', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + union T @context(name: "context") = T1 | T2 + + type T1 @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + a: String! + } + + type T2 @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + b: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T2. Error: Cannot query field "prop" on type "T2".'); + }); it.todo('type mismatch in context variable'); - it.todo('nullability mismatch is ok if contextual value is non-nullable') - it.todo('nullability mismatch is not ok if argument is non-nullable') - it.todo('selection contains more than one value'); + it('nullability mismatch is ok if contextual value is non-nullable', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } - it('trying some stuff', () => { + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it('nullability mismatch is not ok if argument is non-nullable', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection does not match the expected type "String!"'); + }); + + it('selection contains more than one value', () => { const subgraph1 = { name: 'Subgraph1', utl: 'https://Subgraph1', @@ -539,6 +805,49 @@ describe('setContext tests', () => { prop: String! } + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { id prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: multiple selections are made'); + }); + + it('fields marked @external because of context are not flagged as not used', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! @external + } + type U @key(fields: "id") { id: ID! field ( @@ -555,19 +864,65 @@ describe('setContext tests', () => { type Query { a: Int! } + + type T @key(fields: "id") { + id: ID! + prop: String! + } type U @key(fields: "id") { id: ID! } ` }; + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); - const selection = '{ u { id } }'; - const type = result.schema.elementByCoordinate('T'); - const ss = parseSelectionSet({ parentType: type as any, source: selection }); - console.log(ss); + }); + + // Since it's possible that we have to call into the same subggraph with multiple fetch groups where we would have previously used only one, + // we need to verify that there is a resolvable key on the object that uses a context. + it('at least one key on an object that uses a context must be resolvable', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + type U @key(fields: "id", resolvable: false) { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; - }) -}) + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Object \"U\" has no resolvable key but has an a field with a contextual argument.'); + }); +}); diff --git a/internals-js/src/error.ts b/internals-js/src/error.ts index 76efcb718..366004bd1 100644 --- a/internals-js/src/error.ts +++ b/internals-js/src/error.ts @@ -343,6 +343,12 @@ const NO_CONTEXT_IN_SELECTION = makeCodeDefinition( { addedIn: '2.8.0' }, ); +const CONTEXT_NO_RESOLVABLE_KEY = makeCodeDefinition( + 'CONTEXT_NO_RESOLVABLE_KEY', + 'If an ObjectType uses a @fromContext, at least one of its keys must be resolvable.', + { addedIn: '2.8.0' }, +); + const EXTERNAL_TYPE_MISMATCH = makeCodeDefinition( 'EXTERNAL_TYPE_MISMATCH', 'An `@external` field has a type that is incompatible with the declaration(s) of that field in other subgraphs.', @@ -743,6 +749,7 @@ export const ERRORS = { CONTEXT_NOT_SET, CONTEXT_INVALID_SELECTION, NO_CONTEXT_IN_SELECTION, + CONTEXT_NO_RESOLVABLE_KEY, EXTERNAL_TYPE_MISMATCH, EXTERNAL_ARGUMENT_MISSING, EXTERNAL_ARGUMENT_TYPE_MISMATCH, diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 8c2c8cb04..636de035f 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -32,6 +32,7 @@ import { OutputType, WrapperType, isWrapperType, + isNonNullType, } from "./definitions"; import { assert, MultiMap, printHumanReadableList, OrderedMap, mapValues, assertUnreachable } from "./utils"; import { SDLValidationRule } from "graphql/validation/ValidationContext"; @@ -502,6 +503,28 @@ const validateSelectionFormat = ({ } } +/** + * Check to see if the requested type can be fulfilled by the fulfilling type. For example, String! can always be used for String + * + */ +function canFulfillType(requestedType: NamedType | InputType, fulfillingType: NamedType | InputType): boolean { + assert(requestedType.kind !== 'ObjectType' + && requestedType.kind !== 'UnionType' && + fulfillingType.kind !== 'ObjectType' && + fulfillingType.kind !== 'UnionType', 'Expected an input type or wrapped input type'); + + if (requestedType.toString() === fulfillingType.toString()) { + return true; + } + if (isWrapperType(requestedType) && isWrapperType(fulfillingType)) { + return canFulfillType(requestedType.baseType(), fulfillingType.baseType()); + } + if (!isNonNullType(requestedType) && isNonNullType(fulfillingType)) { + return canFulfillType(requestedType, fulfillingType.baseType()); + } + return false; +} + function validateFieldValue({ context, selection, @@ -516,6 +539,7 @@ function validateFieldValue({ errorCollector: GraphQLError[], }): void { const expectedType = fromContextParent.type; + assert(expectedType, 'Expected a type'); const { selectionType, } = validateSelectionFormat({ context, selection, fromContextParent, errorCollector }); @@ -528,69 +552,79 @@ function validateFieldValue({ for (const location of setContextLocations) { // for each location, we need to validate that the selection will result in exactly one field being selected // the number of selection sets created will be the same - const selectionSet = parseSelectionSet({ parentType: location, source: selection}); - if (selectionType === 'field') { - const { resolvedType } = validateFieldValueType({ - currentType: location, - selectionSet, - errorCollector, - }); - if (resolvedType === undefined || resolvedType.toString() !== expectedType?.toString()) { + const explodedTypes = location.kind === 'UnionType' ? location.types() : [location]; + for (const explodedType of explodedTypes) { + let selectionSet: SelectionSet; + try { + selectionSet = parseSelectionSet({ parentType: explodedType, source: selection}); + } catch (e) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid for type ${explodedType.name}. Error: ${e.message}`, { nodes: sourceASTs(fromContextParent) } )); return; } - } else if (selectionType === 'inlineFragment') { - // ensure that each location maps to exactly one fragment - const types = selectionSet.selections() - .filter((s): s is FragmentSelection => s.kind === 'FragmentSelection') - .filter(s => { - const { typeCondition } = s.element; - assert(typeCondition, 'Expected a type condition on FragmentSelection'); - if (typeCondition.kind === 'ObjectType') { - return location.name === typeCondition.name; - } else if (typeCondition.kind === 'InterfaceType') { - return location.kind === 'InterfaceType' ? location.name === typeCondition.name : typeCondition.isPossibleRuntimeType(location); - } else if (typeCondition.kind === 'UnionType') { - if (location.kind === 'InterfaceType') { - return false; - } else if (location.kind === 'UnionType') { - return typeCondition.name === location.name; - } else { - return typeCondition.types().includes(location); - } - } else { - assertUnreachable(typeCondition); - } - }); - - if (types.length === 0) { - errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no type condition matches the location "${location.coordinate}"`, - { nodes: sourceASTs(fromContextParent) } - )); - return; - } else if (types.length > 1) { - errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple type conditions match the location "${location.coordinate}"`, - { nodes: sourceASTs(fromContextParent) } - )); - return; - } else { + if (selectionType === 'field') { const { resolvedType } = validateFieldValueType({ - currentType: location, - selectionSet: types[0].selectionSet, + currentType: explodedType, + selectionSet, errorCollector, }); - if (resolvedType === undefined || resolvedType.toString() !== expectedType?.toString()) { + if (resolvedType === undefined || !canFulfillType(expectedType!, resolvedType)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, { nodes: sourceASTs(fromContextParent) } )); return; } + } else if (selectionType === 'inlineFragment') { + // ensure that each explodedType maps to exactly one fragment + const types = selectionSet.selections() + .filter((s): s is FragmentSelection => s.kind === 'FragmentSelection') + .filter(s => { + const { typeCondition } = s.element; + assert(typeCondition, 'Expected a type condition on FragmentSelection'); + if (typeCondition.kind === 'ObjectType') { + return explodedType.name === typeCondition.name; + } else if (typeCondition.kind === 'InterfaceType') { + return explodedType.kind === 'InterfaceType' ? explodedType.name === typeCondition.name : typeCondition.isPossibleRuntimeType(explodedType); + } else if (typeCondition.kind === 'UnionType') { + if (explodedType.kind === 'InterfaceType') { + return false; + } else { + return typeCondition.types().includes(explodedType); + } + } else { + assertUnreachable(typeCondition); + } + }); + + if (types.length === 0) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no type condition matches the location "${explodedType.coordinate}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } else if (types.length > 1) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple type conditions match the location "${explodedType.coordinate}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } else { + const { resolvedType } = validateFieldValueType({ + currentType: explodedType, + selectionSet: types[0].selectionSet, + errorCollector, + }); + if (resolvedType === undefined || !canFulfillType(expectedType!, resolvedType)) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } + } } } } @@ -664,7 +698,14 @@ export function collectUsedFields(metadata: FederationMetadata): Set( + metadata, + type => type, + usedFields, + ); + // Collects all fields used to satisfy an interface constraint for (const itfType of metadata.schema.interfaceTypes()) { const runtimeTypes = itfType.possibleRuntimeTypes(); @@ -681,6 +722,81 @@ export function collectUsedFields(metadata: FederationMetadata): Set>( + metadata: FederationMetadata, + targetTypeExtractor: (element: TParent) => CompositeType | undefined, + usedFieldDefs: Set> +) { + const fromContextDirective = metadata.fromContextDirective(); + const contextDirective = metadata.contextDirective(); + + // if one of the directives is not defined, there's nothing to validate + if (!isFederationDirectiveDefinedInSchema(fromContextDirective) || !isFederationDirectiveDefinedInSchema(contextDirective)) { + return; + } + + // build the list of context entry points + const entryPoints = new Map>(); + for (const application of contextDirective.applications()) { + const type = targetTypeExtractor(application.parent! as TParent); + if (!type) { + // Means the application is wrong: we ignore it here as later validation will detect it + continue; + } + const context = application.arguments().name; + if (!entryPoints.has(context)) { + entryPoints.set(context, new Set()); + } + entryPoints.get(context)!.add(type); + } + + for (const application of fromContextDirective.applications()) { + const type = targetTypeExtractor(application.parent! as TParent); + if (!type) { + // Means the application is wrong: we ignore it here as later validation will detect it + continue; + } + + const fieldValue = application.arguments().field; + const { context, selection } = parseContext(fieldValue); + + if (!context) { + continue; + } + + // now we need to collect all the fields used for every type that they could be used for + const contextTypes = entryPoints.get(context); + if (!contextTypes) { + continue; + } + + for (const contextType of contextTypes) { + try { + // helper function + const fieldAccessor = (t: CompositeType, f: string) => { + const field = t.field(f); + if (field) { + usedFieldDefs.add(field); + if (isInterfaceType(t)) { + for (const implType of t.possibleRuntimeTypes()) { + const implField = implType.field(f); + if (implField) { + usedFieldDefs.add(implField); + } + } + } + } + return field; + }; + + parseSelectionSet({ parentType: contextType, source: selection, fieldAccessor }); + } catch (e) { + // ignore the error, it will be caught later + } + } + } +} + function collectUsedFieldsForDirective>( definition: DirectiveDefinition<{fields: any}>, targetTypeExtractor: (element: TParent) => CompositeType | undefined, @@ -719,7 +835,6 @@ function validateAllExternalFieldsUsed(metadata: FederationMetadata, errorCollec if (!metadata.isFieldExternal(field) || metadata.isFieldUsed(field)) { continue; } - errorCollector.push(ERRORS.EXTERNAL_UNUSED.err( `Field "${field.coordinate}" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface;` + ' the field declaration has no use and should be removed (or the field should not be @external).', @@ -1407,6 +1522,17 @@ export class FederationBlueprint extends SchemaBlueprint { }); } } + + // validate that there is at least one resolvable key on the type + const keyDirective = metadata.keyDirective(); + const objectType = parent.parent.parent; + const keyApplications = objectType.appliedDirectivesOf(keyDirective); + if (!keyApplications.some(app => app.arguments().resolvable || app.arguments().resolvable === undefined)) { + errorCollector.push(ERRORS.CONTEXT_NO_RESOLVABLE_KEY.err( + `Object "${objectType.coordinate}" has no resolvable key but has an a field with a contextual argument.`, + { nodes: sourceASTs(objectType) } + )); + } } } diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index 0b3693976..c094edae1 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -231,6 +231,7 @@ export class Field ex definition: FieldDefinition, assumeValid: boolean = false, variableDefinitions?: VariableDefinitions, + contextualArguments?: string[], ): boolean { assert(assumeValid || variableDefinitions, 'Must provide variable definitions if validation is needed'); @@ -250,7 +251,7 @@ export class Field ex for (const argDef of definition.arguments()) { const appliedValue = this.argumentValue(argDef.name); if (appliedValue === undefined) { - if (argDef.defaultValue === undefined && !isNullableType(argDef.type!)) { + if (argDef.defaultValue === undefined && !isNullableType(argDef.type!) && !contextualArguments || !contextualArguments?.includes(argDef.name)) { return false; } } else { @@ -278,8 +279,10 @@ export class Field ex for (const argDef of this.definition.arguments()) { const appliedValue = this.argumentValue(argDef.name); if (appliedValue === undefined) { + // TODO: This is a hack that will not work if directives are renamed. Not sure how to fix as we're missing metadata + const isContextualArg = !!argDef.appliedDirectives.find(d => d.name === 'federation__fromContext'); validate( - argDef.defaultValue !== undefined || isNullableType(argDef.type!), + argDef.defaultValue !== undefined || isNullableType(argDef.type!) || isContextualArg, () => `Missing mandatory value for argument "${argDef.name}" of field "${this.definition.coordinate}" in selection "${this}"`); } else { validate( diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index 55b7a58da..6539362b5 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -20,6 +20,7 @@ export const DEFAULT_SUPPORTED_SUPERGRAPH_FEATURES = new Set([ 'https://specs.apollo.dev/tag/v0.3', 'https://specs.apollo.dev/inaccessible/v0.1', 'https://specs.apollo.dev/inaccessible/v0.2', + 'https://specs.apollo.dev/context/v0.1', ]); const coreVersionZeroDotOneUrl = FeatureUrl.parse('https://specs.apollo.dev/core/v0.1'); diff --git a/internals-js/src/utils.ts b/internals-js/src/utils.ts index 9022ae2de..fa1bce8ab 100644 --- a/internals-js/src/utils.ts +++ b/internals-js/src/utils.ts @@ -441,3 +441,13 @@ export function findLast(array: T[], predicate: (t: T) => boolean): T | undef } return undefined; } + +export function mergeMapOrNull(m1: Map | null, m2: Map | null): Map | null { + if (!m1) { + return m2; + } + if (!m2) { + return m1; + } + return new Map([...m1, ...m2]); +} diff --git a/query-graphs-js/src/conditionsCaching.ts b/query-graphs-js/src/conditionsCaching.ts index 3168e0492..cbcf6b213 100644 --- a/query-graphs-js/src/conditionsCaching.ts +++ b/query-graphs-js/src/conditionsCaching.ts @@ -1,4 +1,4 @@ -import { assert } from "@apollo/federation-internals"; +import { SelectionSet, assert } from "@apollo/federation-internals"; import { ConditionResolution, ConditionResolver, ExcludedConditions, ExcludedDestinations, sameExcludedDestinations } from "./graphPath"; import { PathContext } from "./pathContext"; import { Edge, QueryGraph, QueryGraphState } from "./querygraph"; @@ -14,8 +14,8 @@ export function cachingConditionResolver(graph: QueryGraph, resolver: ConditionR // as the algorithm always try keys in the same order (the order of the edges in the query graph), including // the excluded edges we see on the first ever call is actually the proper thing to do. const cache = new QueryGraphState(graph); - return (edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions) => { - assert(edge.conditions, 'Should not have been called for edge without conditions'); + return (edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions, extraConditions?: SelectionSet) => { + assert(edge.conditions || extraConditions, 'Should not have been called for edge without conditions'); // We don't cache if there is a context or excluded conditions because those would impact the resolution and // we don't want to cache a value per-context and per-excluded-conditions (we also don't cache per-excluded-edges though @@ -26,7 +26,7 @@ export function cachingConditionResolver(graph: QueryGraph, resolver: ConditionR // cached value `pathTree` when the context is not empty. That said, the context is about active @include/@skip and it's not use // that commonly, so this is probably not an urgent improvement. if (!context.isEmpty() || excludedConditions.length > 0) { - return resolver(edge, context, excludedDestinations, excludedConditions); + return resolver(edge, context, excludedDestinations, excludedConditions, extraConditions); } const cachedResolutionAndExcludedEdges = cache.getEdgeState(edge); @@ -34,9 +34,9 @@ export function cachingConditionResolver(graph: QueryGraph, resolver: ConditionR const [cachedResolution, forExcludedEdges] = cachedResolutionAndExcludedEdges; return sameExcludedDestinations(forExcludedEdges, excludedDestinations) ? cachedResolution - : resolver(edge, context, excludedDestinations, excludedConditions); + : resolver(edge, context, excludedDestinations, excludedConditions, extraConditions); } else { - const resolution = resolver(edge, context, excludedDestinations, excludedConditions); + const resolution = resolver(edge, context, excludedDestinations, excludedConditions, extraConditions); cache.setEdgeState(edge, [resolution, excludedDestinations]); return resolution; } diff --git a/query-graphs-js/src/conditionsValidation.ts b/query-graphs-js/src/conditionsValidation.ts index 74a6cbf83..85231e55f 100644 --- a/query-graphs-js/src/conditionsValidation.ts +++ b/query-graphs-js/src/conditionsValidation.ts @@ -1,4 +1,4 @@ -import { Schema, Selection } from "@apollo/federation-internals"; +import { Schema, Selection, SelectionSet } from "@apollo/federation-internals"; import { ConditionResolution, ConditionResolver, @@ -82,8 +82,9 @@ export function simpleValidationConditionResolver({ context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions, + extraConditions?: SelectionSet, ): ConditionResolution => { - const conditions = edge.conditions!; + const conditions = (edge.conditions ?? extraConditions)!; // TODO: ensure that only one is set excludedConditions = addConditionExclusion(excludedConditions, conditions); const initialPath: OpGraphPath = GraphPath.create(queryGraph, edge.head); diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 5725bdb2d..40d782eb9 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -29,6 +29,7 @@ import { DeferDirectiveArgs, isInterfaceType, isSubset, + parseSelectionSet, } from "@apollo/federation-internals"; import { OpPathTree, traversePathTree } from "./pathTree"; import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRootType, FEDERATED_GRAPH_ROOT_SOURCE } from "./querygraph"; @@ -160,9 +161,15 @@ type PathProps | null)[], + + /** This parameter is for mapping contexts back to the parameter used to collect the field */ + readonly parameterToContext: readonly (Map | null)[], } -export class GraphPath implements Iterable<[Edge | TNullEdge, TTrigger, OpPathTree | null]> { +export class GraphPath implements Iterable<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { private constructor( private readonly props: PathProps, ) { @@ -207,6 +214,8 @@ export class GraphPath { + next(): IteratorResult<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { if (this.currentIndex >= path.size) { return { done: true, value: undefined }; } @@ -371,7 +380,13 @@ export class GraphPath { assert(!edge || this.tail.index === edge.head.index, () => `Cannot add edge ${edge} to path ending at ${this.tail}`); assert(conditionsResolution.satisfied, 'Should add to a path if the conditions cannot be satisfied'); - assert(!edge || edge.conditions || !conditionsResolution.pathTree, () => `Shouldn't have conditions paths (got ${conditionsResolution.pathTree}) for edge without conditions (edge: ${edge})`); + assert(!edge || edge.conditions || edge.requiredContexts.length > 0 || !conditionsResolution.pathTree, () => `Shouldn't have conditions paths (got ${conditionsResolution.pathTree}) for edge without conditions (edge: ${edge})`); // We clear `subgraphEnteringEdge` as we enter a @defer: that is because `subgraphEnteringEdge` is used to eliminate some // non-optimal paths, but we don't want those optimizations to bypass a defer. @@ -503,13 +518,15 @@ export class GraphPath | null)[], + parameterToContext: (Map | null)[], + }{ + const edgeConditions = this.props.edgeConditions.concat(conditionsResolution.pathTree ?? null); + const contextToSelection = this.props.contextToSelection.concat(null); + const parameterToContext = this.props.parameterToContext.concat(null); + + if (conditionsResolution.contextMap === undefined || conditionsResolution.contextMap.size === 0) { + return { + edgeConditions, + contextToSelection, + parameterToContext, + }; + } + + // A context could be used in a different way in a different path, so we need to randomize the context names + parameterToContext[parameterToContext.length-1] = new Map(); + + for (const [_, entry] of conditionsResolution.contextMap) { + const idx = edgeConditions.length - entry.level -1; + assert(idx >= 0, 'calculated condition index must be positive'); + if (edgeConditions[idx] === null) { + edgeConditions[idx] = entry.pathTree ?? null; + } else if (entry.pathTree !== null) { + // here we need to merge the two OpPathTrees. TODO: Do this all at once + edgeConditions[idx] = entry.pathTree?.merge(edgeConditions[idx]!) ?? null; + } + if (contextToSelection[idx] === null) { + contextToSelection[idx] = new Map(); + } + contextToSelection[idx]?.set(entry.uuid, entry.selectionSet); + parameterToContext[parameterToContext.length-1]?.set(entry.paramName, entry.uuid); + } + return { + edgeConditions, + contextToSelection, + parameterToContext, + }; + } + /** * Creates a new path corresponding to concatenating the provide path _after_ this path. * @@ -808,7 +873,7 @@ export class GraphPath extends Iterator<[Edge | TNullEdge, TTrigger, OpPathTree | null]> { +export interface PathIterator extends Iterator<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { currentIndex: number, currentVertex: Vertex } @@ -879,19 +944,30 @@ export function traversePath( // Note that ConditionResolver are guaranteed to be only called for edge with conditions. export type ConditionResolver = - (edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions) => ConditionResolution; + (edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions, extraConditions?: SelectionSet) => ConditionResolution; +type ContextMapEntry = { + level: number, // level 0 is on the current edge, each incremented number refers to an additional edge on the OpPathTree + pathTree?: OpPathTree, + selectionSet: SelectionSet, + inboundEdge: Edge, + paramName: string, + uuid: string, // a random string because a single context might have different values depending on usage +} + export type ConditionResolution = { satisfied: boolean, cost: number, - pathTree?: OpPathTree + pathTree?: OpPathTree, + contextMap?: Map, // Note that this is not guaranteed to be set even if satistied === false. unsatisfiedConditionReason?: UnsatisfiedConditionReason } export enum UnsatisfiedConditionReason { - NO_POST_REQUIRE_KEY + NO_POST_REQUIRE_KEY, + NO_CONTEXT_SET } export const noConditionsResolution: ConditionResolution = { satisfied: true, cost: 0 }; @@ -1786,12 +1862,60 @@ function canSatisfyConditions 0) { + // if one of the conditions fails to satisfy, it's ok to bail + let someSelectionUnsatisfied = false; + let totalCost = 0; + const contextMap = new Map(); + for (const cxt of requiredContexts) { + let level = 1; + for (const [e] of [...path].reverse()) { + if (e !== null && !contextMap.has(cxt.context) && !someSelectionUnsatisfied) { + const parentType = e.head.type; + if (isCompositeType(parentType) && cxt.typesWithContextSet.has(parentType.name)) { + const selectionSet = parseSelectionSet({ parentType, source: cxt.selection }); + const resolution = conditionResolver(e, context, excludedEdges, excludedConditions, selectionSet); + contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, uuid: uuidv4() }); + someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; + if (resolution.cost === -1 || totalCost === -1) { + totalCost = -1; + } else { + totalCost += resolution.cost; + } + } + } + level += 1; + } + } + + if (requiredContexts.some(c => !contextMap.has(c.context))) { + // in this case there is a context that is unsatisfied. Return no path. + debug.groupEnd('@fromContext requires a context that is not set in graph path'); + return { ...unsatisfiedConditionsResolution, unsatisfiedConditionReason: UnsatisfiedConditionReason.NO_CONTEXT_SET }; + } + + if (someSelectionUnsatisfied) { + debug.groupEnd('@fromContext selection set is unsatisfied'); + return { ...unsatisfiedConditionsResolution }; + } + + // it's possible that we will need to create a new fetch group at this point, in which case we'll need to collect the keys + // to jump back to this object as a precondition for satisfying it. + const keyEdge = path.graph.outEdges(edge.head).find(e => e.transition.kind === 'KeyResolution'); + assert(keyEdge, () => `Expected to find a key edge from ${edge.head}`); + + const r = conditionResolver(keyEdge, context, excludedEdges, excludedConditions, keyEdge.conditions); + + return { contextMap, cost: totalCost, satisfied: true, pathTree: r.pathTree }; + } + debug.group(() => `Checking conditions ${conditions} on edge ${edge}`); const resolution = conditionResolver(edge, context, excludedEdges, excludedConditions); if (!resolution.satisfied) { @@ -2672,7 +2796,7 @@ function edgeForField( const candidates = graph.outEdges(vertex) .filter(e => e.transition.kind === 'FieldCollection' - && field.selects(e.transition.definition, true) + && field.selects(e.transition.definition, true, undefined, e.requiredContexts?.map(c => c.namedParameter)) && e.satisfiesOverrideConditions(overrideConditions) ); assert(candidates.length <= 1, () => `Vertex ${vertex} has multiple edges matching ${field} (${candidates})`); diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index 3eb82860f..055970199 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -1,4 +1,4 @@ -import { arrayEquals, assert, copyWitNewLength, SelectionSet } from "@apollo/federation-internals"; +import { arrayEquals, assert, copyWitNewLength, mergeMapOrNull, SelectionSet } from "@apollo/federation-internals"; import { GraphPath, OpGraphPath, OpTrigger, PathIterator } from "./graphPath"; import { Edge, QueryGraph, RootVertex, isRootVertex, Vertex } from "./querygraph"; import { isPathContext } from "./pathContext"; @@ -25,7 +25,7 @@ type Child = { function findTriggerIdx( triggerEquality: (t1: TTrigger, t2: TTrigger) => boolean, - forIndex: [TTrigger, OpPathTree | null, TElements][], + forIndex: [TTrigger, OpPathTree | null, TElements, Map | null, Map | null][] | [TTrigger, OpPathTree | null, TElements][], trigger: TTrigger ): number { for (let i = 0; i < forIndex.length; i++) { @@ -48,6 +48,8 @@ export class PathTree boolean, private readonly childs: Child[], + readonly contextToSelection: Map | null, + readonly parameterToContext: Map | null, ) { } @@ -56,7 +58,7 @@ export class PathTree boolean ): PathTree { - return new PathTree(graph, root, undefined, triggerEquality, []); + return new PathTree(graph, root, undefined, triggerEquality, [], null, null); } static createOp(graph: QueryGraph, root: RV): OpPathTree { @@ -86,7 +88,7 @@ export class PathTree { const maxEdges = graph.outEdgesCount(currentVertex); // We store 'null' edges at `maxEdges` index - const forEdgeIndex: [TTrigger, OpPathTree | null, IterAndSelection[]][][] = new Array(maxEdges + 1); + const forEdgeIndex: [TTrigger, OpPathTree | null, IterAndSelection[], Map | null, Map | null][][] = new Array(maxEdges + 1); const newVertices: Vertex[] = new Array(maxEdges); const order: number[] = new Array(maxEdges + 1); let currentOrder = 0; @@ -100,7 +102,7 @@ export class PathTree | null = null; + let mergedParameterToContext: Map | null = null; + const childs: Child[] = new Array(totalChilds); let idx = 0; for (let i = 0; i < currentOrder; i++) { @@ -135,17 +142,19 @@ export class PathTree `Expected to have ${totalChilds} childs but only ${idx} added`); - return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs); + return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs, mergedContextToSelection, mergedParameterToContext); // TODO: I think this is right? } // Assumes all root are rooted on the same vertex @@ -222,7 +231,7 @@ export class PathTree `Expected to have ${totalChilds} childs but only ${idx} added`); - return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs); + return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs, null, null); } childCount(): number { @@ -311,6 +320,9 @@ export class PathTree `Expected ${newSize} childs but only got ${addIdx}`); - return new PathTree(this.graph, this.vertex, localSelections, this.triggerEquality, newChilds); + return new PathTree(this.graph, this.vertex, localSelections, this.triggerEquality, newChilds, mergedContextToSelection, mergedParameterToContext); } private equalsSameRoot(that: PathTree): boolean { @@ -353,7 +365,9 @@ export class PathTree): PathTree { @@ -368,14 +382,14 @@ export class PathTree(this.graph, currentVertex, undefined, this.triggerEquality, this.childsFromPathElements(currentVertex, elements)) + tree: new PathTree(this.graph, currentVertex, undefined, this.triggerEquality, this.childsFromPathElements(currentVertex, elements), contextToSelection, parameterToContext) }]; } @@ -384,7 +398,7 @@ export class PathTree `Next element head of ${edge} is not equal to current tree vertex ${this.vertex}`); const edgeIndex = (edge ? edge.index : null) as number | TNullEdge; const idx = this.findIndex(trigger, edgeIndex); @@ -399,8 +413,10 @@ export class PathTree(this.graph, currentVertex, undefined, this.triggerEquality, this.childsFromPathElements(currentVertex, elements)) - }) + tree: new PathTree(this.graph, currentVertex, undefined, this.triggerEquality, this.childsFromPathElements(currentVertex, elements), contextToSelection, parameterToContext) + }), + null, + null, ); } else { const newChilds = this.childs.concat(); @@ -411,7 +427,7 @@ export class PathTree(this.graph, this.vertex, undefined, this.triggerEquality, newChilds); + return new PathTree(this.graph, this.vertex, undefined, this.triggerEquality, newChilds, contextToSelection, parameterToContext); } } diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index 97291d456..993727c46 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -36,6 +36,7 @@ import { Supergraph, NamedSchemaElement, validateSupergraph, + parseContext, } from '@apollo/federation-internals'; import { inspect } from 'util'; import { DownCast, FieldCollection, subgraphEnteringTransition, SubgraphEnteringTransition, Transition, KeyResolution, RootTypeResolution, InterfaceObjectFakeDownCast } from './transition'; @@ -123,6 +124,13 @@ export interface OverrideCondition { condition: boolean; } +export type ContextCondition = { + context: string; + namedParameter: string; + selection: string; + typesWithContextSet: Set; +} + /** * An edge of a query graph. * @@ -131,7 +139,9 @@ export interface OverrideCondition { */ export class Edge { private _conditions?: SelectionSet; - + + public requiredContexts: ContextCondition[] = []; + constructor( /** * Index used for this edge in the query graph it is part of (note that this index is "scoped" within @@ -178,8 +188,17 @@ export class Edge { * matches the query plan parameters, this edge can be taken. */ public overrideCondition?: OverrideCondition, + + /** + * Potentially multiple context conditions. When @fromContext is used on a argument definition, the edge connecting the type to the + * argument needs to reflect that the condition must be satisifed in order for the edge to be taken + */ + requiredContexts?: ContextCondition[], ) { this._conditions = conditions; + if (requiredContexts) { + this.requiredContexts = [...requiredContexts]; + } } get conditions(): SelectionSet | undefined { @@ -229,6 +248,7 @@ export class Edge { this.transition, this._conditions, this.overrideCondition, + this.requiredContexts, ); } @@ -237,6 +257,10 @@ export class Edge { ? new SelectionSetUpdates().add(this._conditions).add(newConditions).toSelectionSet(this._conditions.parentType) : newConditions; } + + addToContextConditions(contextConditions: ContextCondition[]) { + this.requiredContexts.push(...contextConditions); + } isKeyOrRootTypeEdgeToSelf(): boolean { return this.head === this.tail && (this.transition.kind === 'KeyResolution' || this.transition.kind === 'RootTypeResolution'); @@ -848,6 +872,66 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr updateEdgeWithOverrideCondition(fromSubgraph, label, false); } } + + /** + * Now we'll handle instances of @fromContext. For each instance where @fromContext exists, I want to add edges back to each place + * place where the context is set, along with conditions on the edge that goes to the field + */ + for (const [i, subgraph] of subgraphs.entries()) { + const subgraphSchema = schemas[i]; + const subgraphMetadata = federationMetadata(subgraphSchema); + assert(subgraphMetadata, `Subgraph ${i} is not a valid federation subgraph`); + + const contextNameToTypes: Map> = new Map(); + + for (const application of subgraphMetadata.contextDirective().applications()) { + const { name: context } = application.arguments(); + if (contextNameToTypes.has(context)) { + contextNameToTypes.get(context)!.add(application.parent.name); + } else { + contextNameToTypes.set(context, new Set([application.parent.name])); + } + } + + const coordinateMap: Map = new Map(); + for (const application of subgraphMetadata.fromContextDirective().applications()) { + const { field } = application.arguments(); + const { context, selection } = parseContext(field); + + assert(context, () => `FieldValue has invalid format. Context not found ${field}`); + assert(selection, () => `FieldValue has invalid format. Selection not found ${field}`); + const namedParameter = application.parent.name; + const fieldCoordinate = application.parent.parent.coordinate; + const typesWithContextSet = contextNameToTypes.get(context); + assert(typesWithContextSet, () => `Context ${context} is never set in subgraph`); + const z = coordinateMap.get(fieldCoordinate); + if (z) { + z.push({ namedParameter, context, selection, typesWithContextSet }); + } else { + coordinateMap.set(fieldCoordinate, [{ namedParameter, context, selection, typesWithContextSet }]); + } + } + + simpleTraversal( + subgraph, + _v => { return undefined; }, + e => { + if (e.head.type.kind === 'ObjectType' && e.tail.type.kind === 'ScalarType') { + const coordinate = `${e.head.type.name}.${e.transition.toString()}`; + const requiredContexts = coordinateMap.get(coordinate); + if (requiredContexts) { + const headInSupergraph = builder.vertexForTypeAndSubgraph(e.head.type.name, subgraph.name); + assert(headInSupergraph, () => `Vertex for type ${e.head.type.name} not found in supergraph`); + const edgeInSupergraph = builder.edge(headInSupergraph, e.index); + e.addToContextConditions(requiredContexts); + edgeInSupergraph.addToContextConditions(requiredContexts); + } + } + return true; + } + ); + + } // Now we handle @provides let provideId = 0; @@ -1024,15 +1108,19 @@ class GraphBuilder { const indexes = this.typesToVertices.get(typeName); return indexes == undefined ? [] : indexes.map(i => this.vertices[i]); } + + vertexForTypeAndSubgraph(typeName: string, source: string): Vertex | undefined { + return this.verticesForType(typeName).find(v => v.source === source); + } root(kind: SchemaRootKind): Vertex | undefined { return this.rootVertices.get(kind); } - addEdge(head: Vertex, tail: Vertex, transition: Transition, conditions?: SelectionSet, overrideCondition?: OverrideCondition) { + addEdge(head: Vertex, tail: Vertex, transition: Transition, conditions?: SelectionSet, overrideCondition?: OverrideCondition, requiredContexts?: ContextCondition[]) { const headOutEdges = this.outEdges[head.index]; const tailInEdges = this.inEdges[tail.index]; - const edge = new Edge(headOutEdges.length, head, tail, transition, conditions, overrideCondition); + const edge = new Edge(headOutEdges.length, head, tail, transition, conditions, overrideCondition, requiredContexts); headOutEdges.push(edge); tailInEdges.push(edge); @@ -1111,7 +1199,7 @@ class GraphBuilder { const newHead = this.getOrCopyVertex(vertex, offset, graph); for (const edge of graph.outEdges(vertex, true)) { const newTail = this.getOrCopyVertex(edge.tail, offset, graph); - this.addEdge(newHead, newTail, edge.transition, edge.conditions); + this.addEdge(newHead, newTail, edge.transition, edge.conditions, edge.overrideCondition, edge.requiredContexts); } } this.nextIndex += graph.verticesCount(); @@ -1150,7 +1238,7 @@ class GraphBuilder { newVertex.provideId = provideId; newVertex.hasReachableCrossSubgraphEdges = vertex.hasReachableCrossSubgraphEdges; for (const edge of this.outEdges[vertex.index]) { - this.addEdge(newVertex, edge.tail, edge.transition, edge.conditions); + this.addEdge(newVertex, edge.tail, edge.transition, edge.conditions, edge.overrideCondition, edge.requiredContexts); } return newVertex; } @@ -1163,7 +1251,7 @@ class GraphBuilder { * @returns the newly created edge that, as of this method returning, replaces `edge`. */ updateEdgeTail(edge: Edge, newTail: Vertex): Edge { - const newEdge = new Edge(edge.index, edge.head, newTail, edge.transition, edge.conditions, edge.overrideCondition); + const newEdge = new Edge(edge.index, edge.head, newTail, edge.transition, edge.conditions, edge.overrideCondition, edge.requiredContexts); this.outEdges[edge.head.index][edge.index] = newEdge; // For in-edge, we need to remove the edge from the inputs of the previous tail, // and add it to the new tail. diff --git a/query-planner-js/src/QueryPlan.ts b/query-planner-js/src/QueryPlan.ts index dd57cc055..3e2391c13 100644 --- a/query-planner-js/src/QueryPlan.ts +++ b/query-planner-js/src/QueryPlan.ts @@ -40,6 +40,7 @@ export interface FetchNode { // If QP defer support is enabled _and_ the `serviceName` subgraph support defer, then whether `operation` contains some @defer. Unset otherwise. hasDefers?: boolean, variableUsages?: string[]; + contextVariableUsages?: Map; requires?: QueryPlanSelectionNode[]; operation: string; operationName: string | undefined; diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 35fad5afd..9b080984d 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -1,5 +1,6 @@ import { QueryPlanner } from '@apollo/query-planner'; import { + asFed2SubgraphDocument, assert, operationFromDocument, ServiceDefinition, @@ -18,6 +19,7 @@ import { composeAndCreatePlannerWithOptions, } from './testHelper'; import { enforceQueryPlannerConfigDefaults } from '../config'; +import { composeServices } from '@apollo/composition'; describe('shareable root fields', () => { test('can use same root operation from multiple subgraphs in parallel', () => { @@ -8386,3 +8388,321 @@ describe('handles fragments with directive conditions', () => { `); }); }); + +describe('@fromContext impacts on query planning', () => { + it('fromContext variable is from same subgraph', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field(a: String! @fromContext(field: "$context { prop }")): Int! + } + `, + }; + + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + `, + }; + + const asFed2Service = (service: ServiceDefinition) => { + return { + ...service, + typeDefs: asFed2SubgraphDocument(service.typeDefs, { + includeAllImports: true, + }), + }; + }; + + const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { + return composeServices(services.map((s) => asFed2Service(s))); + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + const [api, queryPlanner] = [ + result.schema!.toAPISchema(), + new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + ]; + // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); + const operation = operationFromDocument( + api, + gql` + { + t { + u { + field + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + prop + u { + id + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + id + field + } + } + }, + }, + }, + } + `); + }); + + it('fromContext variable is from different subgraph', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! @external + } + + type U @key(fields: "id") { + id: ID! + field(a: String! @fromContext(field: "$context { prop }")): Int! + } + `, + }; + + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type T @key(fields: "id") { + id: ID! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + } + `, + }; + + const asFed2Service = (service: ServiceDefinition) => { + return { + ...service, + typeDefs: asFed2SubgraphDocument(service.typeDefs, { + includeAllImports: true, + }), + }; + }; + + const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { + return composeServices(services.map((s) => asFed2Service(s))); + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.errors).toBeUndefined(); + const [api, queryPlanner] = [ + result.schema!.toAPISchema(), + new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + ]; + // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); + const operation = operationFromDocument( + api, + gql` + { + t { + u { + id + field + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + prop + u { + id + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + id + field + } + } + }, + }, + }, + } + `); + }); + + it('fromContext variable is a list', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: [String]! + } + + type U @key(fields: "id") { + id: ID! + field(a: [String]! @fromContext(field: "$context { prop }")): Int! + } + `, + }; + + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + `, + }; + + const asFed2Service = (service: ServiceDefinition) => { + return { + ...service, + typeDefs: asFed2SubgraphDocument(service.typeDefs, { + includeAllImports: true, + }), + }; + }; + + const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { + return composeServices(services.map((s) => asFed2Service(s))); + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + const [api, queryPlanner] = [ + result.schema!.toAPISchema(), + new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + ]; + // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); + const operation = operationFromDocument( + api, + gql` + { + t { + u { + field + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + prop + u { + id + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + id + field + } + } + }, + }, + }, + } + `); + }); +}); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index c20ebf3d0..50ee29ac0 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -395,7 +395,7 @@ class QueryPlanningTraversal { this.optionsLimit = parameters.config.debug?.pathsLimit; this.conditionResolver = cachingConditionResolver( federatedQueryGraph, - (edge, context, excludedEdges, excludedConditions) => this.resolveConditionPlan(edge, context, excludedEdges, excludedConditions), + (edge, context, excludedEdges, excludedConditions, extras) => this.resolveConditionPlan(edge, context, excludedEdges, excludedConditions, extras), ); const initialPath: OpGraphPath = GraphPath.create(federatedQueryGraph, root); @@ -751,13 +751,13 @@ class QueryPlanningTraversal { : computeNonRootFetchGroups(dependencyGraph, tree, this.rootKind); } - private resolveConditionPlan(edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions): ConditionResolution { + private resolveConditionPlan(edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions, extraConditions?: SelectionSet): ConditionResolution { const bestPlan = new QueryPlanningTraversal( { ...this.parameters, root: edge.head, }, - edge.conditions!, + (edge.conditions || extraConditions)!, 0, false, 'query', @@ -792,6 +792,7 @@ type ParentRelation = { const conditionsMemoizer = (selectionSet: SelectionSet) => ({ conditions: conditionsOfSelectionSet(selectionSet) }); class GroupInputs { + readonly usedContexts = new Set; private readonly perType = new Map(); onUpdateCallback?: () => void | undefined = undefined; @@ -812,6 +813,10 @@ class GroupInputs { typeSelection.updates().add(selection); this.onUpdateCallback?.(); } + + addContext(context: string) { + this.usedContexts.add(context); + } addAll(other: GroupInputs) { for (const otherSelection of other.perType.values()) { @@ -841,6 +846,14 @@ class GroupInputs { return false; } } + if (this.usedContexts.size < other.usedContexts.size) { + return false; + } + for (const c of other.usedContexts) { + if (!this.usedContexts.has(c)) { + return false; + } + } return true; } @@ -855,6 +868,14 @@ class GroupInputs { return false; } } + if (this.usedContexts.size !== other.usedContexts.size) { + return false; + } + for (const c of other.usedContexts) { + if (!this.usedContexts.has(c)) { + return false; + } + } return true; } @@ -863,6 +884,9 @@ class GroupInputs { for (const [type, selection] of this.perType.entries()) { cloned.perType.set(type, selection.clone()); } + for (const c of this.usedContexts) { + cloned.usedContexts.add(c); + } return cloned; } @@ -892,6 +916,9 @@ class FetchGroup { // Set in some code-path to indicate that the selection of the group not be optimized away even if it "looks" useless. mustPreserveSelection: boolean = false; + // context may not be get and set within the same fetch group, so we need to track which contexts are set + contextSelections?: Map; + private constructor( readonly dependencyGraph: FetchDependencyGraph, public index: number, @@ -1142,6 +1169,12 @@ class FetchGroup { rewrites.forEach((r) => this.inputRewrites.push(r)); } } + + addInputContext(context: string) { + assert(this._inputs, "Shouldn't try to add inputs to a root fetch group"); + + this._inputs.addContext(context); + } copyInputsOf(other: FetchGroup) { if (other.inputs) { @@ -4110,13 +4143,20 @@ function computeGroupsForTree( }); assert(updatedOperation, () => `Extracting @defer from ${operation} should not have resulted in no operation`); - const updated = { + + let updated; + + const { parameterToContext } = tree; + const groupContextSelections = group.contextSelections; + + updated = { tree: child, group, path, context, - deferContext: updatedDeferContext + deferContext: updatedDeferContext, }; + if (conditions) { // We have @requires or some other dependency to create groups for. const requireResult = handleRequires( @@ -4130,6 +4170,17 @@ function computeGroupsForTree( ); updated.group = requireResult.group; updated.path = requireResult.path; + + if (tree.contextToSelection) { + // each of the selections that could be used in a @fromContext paramter should be saved to the fetch group. + // This will also be important in determining when it is necessary to draw a new fetch group boundary + if (updated.group.contextSelections === undefined) { + updated.group.contextSelections = new Map(); + } + for (const [key, value] of tree.contextToSelection) { + updated.group.contextSelections.set(key, value); + } + } updateCreatedGroups(createdGroups, ...requireResult.createdGroups); } @@ -4173,7 +4224,55 @@ function computeGroupsForTree( updated.path = updated.path.add(updatedOperation); } - stack.push(updated); + // if we're going to start using context variables, every variable used must be set in a different parent + // fetch group or else we need to create a new one + if (parameterToContext && groupContextSelections && Array.from(parameterToContext.values()).some(c => groupContextSelections.has(c))) { + // let's find the edge that will be used as an entry to the new type in the subgraph + const entityVertex = dependencyGraph.federatedQueryGraph.verticesForType(edge.head.type.name).find(v => v.source === edge.tail.source); + assert(entityVertex, () => `Could not find entity entry edge for ${edge.head.source}`); + const keyResolutionEdge = dependencyGraph.federatedQueryGraph.outEdges(entityVertex).find(e => e.transition.kind === 'KeyResolution'); + assert(keyResolutionEdge, () => `Could not find key resolution edge for ${edge.head.source}`); + + const type = edge.head.type as CompositeType; + const newGroup = dependencyGraph.getOrCreateKeyFetchGroup({ + subgraphName: edge.tail.source, + mergeAt: path.inResponse(), + type, + parent: { group, path: path.inGroup() }, + conditionsGroups: [], + }); + newGroup.addParent({ group, path: path.inGroup() }); + for (const [_, value] of parameterToContext) { + newGroup.addInputContext(value); + } + + const inputType = dependencyGraph.typeForFetchInputs(type.name); + const inputSelections = newCompositeTypeSelectionSet(inputType); + inputSelections.updates().add(keyResolutionEdge.conditions!); + newGroup.addInputs( + wrapInputsSelections(inputType, inputSelections.get(), context), // TODO: is the context right + computeInputRewritesOnKeyFetch(inputType.name, type), + ); + + updateCreatedGroups(createdGroups, newGroup); + + // TODO: There is currently a problem where we are getting the current field in the previous fetch group, where + // we really want to get the condition only. To be fixed. + if (conditions) { + stack.push(updated); + } + + stack.push({ + tree, + group: newGroup, + path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, type, context)), + context, + deferContext: updatedDeferContext, + }); + + } else { + stack.push(updated); + } } } } From 1f495a649c006bde485ce816b81b63446d4de4df Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 22 Apr 2024 18:46:21 -0500 Subject: [PATCH 06/82] fix a couple of tests --- internals-js/src/operations.ts | 2 +- query-graphs-js/src/graphPath.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index c094edae1..71745022d 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -251,7 +251,7 @@ export class Field ex for (const argDef of definition.arguments()) { const appliedValue = this.argumentValue(argDef.name); if (appliedValue === undefined) { - if (argDef.defaultValue === undefined && !isNullableType(argDef.type!) && !contextualArguments || !contextualArguments?.includes(argDef.name)) { + if (argDef.defaultValue === undefined && !isNullableType(argDef.type!) && (!contextualArguments || !contextualArguments?.includes(argDef.name))) { return false; } } else { diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 40d782eb9..484d6bfe5 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1880,7 +1880,15 @@ function canSatisfyConditions 1) { + const fragmentSelection = selectionSet.selections().find(s => s.kind === 'FragmentSelection' && s.element.typeCondition?.name === parentType.name); + if (fragmentSelection) { + selectionSet = fragmentSelection.selectionSet!; + } + } const resolution = conditionResolver(e, context, excludedEdges, excludedConditions, selectionSet); contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, uuid: uuidv4() }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; From 4ed6fdc8e9f8467c353af5fc7d130bf56f68854e Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 23 Apr 2024 19:23:02 -0500 Subject: [PATCH 07/82] Change from uuid for generation to something that is more gql friendly (nanoid). Also modify triggers to include contextual arguments when we reach leaf fields. --- internals-js/src/operations.ts | 11 +++++++++++ package-lock.json | 18 ++++++++++++++++++ query-graphs-js/package.json | 1 + query-graphs-js/src/graphPath.ts | 20 +++++++++++++++++--- query-planner-js/src/buildPlan.ts | 5 +++-- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index 71745022d..78c56093e 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -168,6 +168,17 @@ export class Field ex baseType(): NamedType { return baseType(this.definition.type!); } + + withUpdatedArguments(newArgs: TArgs): Field { + const newField = new Field( + this.definition, + this.args ? this.args.merge(newArgs) : newArgs, + this.appliedDirectives, + this.alias, + ); + this.copyAttachementsTo(newField); + return newField; + } withUpdatedDefinition(newDefinition: FieldDefinition): Field { const newField = new Field( diff --git a/package-lock.json b/package-lock.json index 750c14322..4abc8d347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13631,6 +13631,23 @@ "license": "MIT", "optional": true }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/nanomatch": { "version": "1.2.13", "dev": true, @@ -17832,6 +17849,7 @@ "dependencies": { "@apollo/federation-internals": "2.7.2", "deep-equal": "^2.0.5", + "nanoid": "^3.3.6", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" }, diff --git a/query-graphs-js/package.json b/query-graphs-js/package.json index 68fce655a..bf960dea3 100644 --- a/query-graphs-js/package.json +++ b/query-graphs-js/package.json @@ -25,6 +25,7 @@ "dependencies": { "@apollo/federation-internals": "2.7.2", "deep-equal": "^2.0.5", + "nanoid": "^3.3.6", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" }, diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 484d6bfe5..8a6e6c9a7 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -36,6 +36,9 @@ import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRoo import { DownCast, Transition } from "./transition"; import { PathContext, emptyContext } from "./pathContext"; import { v4 as uuidv4 } from 'uuid'; +import { customAlphabet } from 'nanoid'; + +const idGen = customAlphabet('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); const debug = newDebugLogger('path'); @@ -520,11 +523,21 @@ export class GraphPath { + acc[key] = `$${value}`; + return acc; + }, {}); + newTrigger = (trigger as Field).withUpdatedArguments(args) as TTrigger; + } + return new GraphPath({ ...this.props, tail: edge ? edge.tail : this.tail, - edgeTriggers: this.props.edgeTriggers.concat(trigger), + edgeTriggers: this.props.edgeTriggers.concat(newTrigger), edgeIndexes: this.props.edgeIndexes.concat((edge ? edge.index : null) as number | TNullEdge), edgeConditions, subgraphEnteringEdge, @@ -1890,7 +1903,8 @@ function canSatisfyConditions `Extracting @defer from ${operation} should not have resulted in no operation`); - let updated; const { parameterToContext } = tree; @@ -4718,7 +4717,9 @@ function inputsForRequire( assert(inputType && isCompositeType(inputType), () => `Type ${inputTypeName} should exist in the supergraph and be a composite type`); const fullSelectionSet = newCompositeTypeSelectionSet(inputType); - fullSelectionSet.updates().add(edge.conditions!); + if (edge.conditions) { + fullSelectionSet.updates().add(edge.conditions); + } let keyInputs: MutableSelectionSet | undefined = undefined; if (includeKeyInputs) { const keyCondition = getLocallySatisfiableKey(dependencyGraph.federatedQueryGraph, edge.head); From dffd92db09d5113a9d07a4935d3987e02fba7eaf Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Wed, 24 Apr 2024 22:20:53 -0500 Subject: [PATCH 08/82] Add inputRewrites for contextual values --- query-planner-js/src/buildPlan.ts | 53 ++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index aba40b60e..2ff9de1eb 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -61,6 +61,7 @@ import { typesCanBeMerged, Supergraph, sameType, + assertUnreachable, } from "@apollo/federation-internals"; import { advanceSimultaneousPathsWithOperation, @@ -96,7 +97,7 @@ import { FEDERATED_GRAPH_ROOT_SOURCE, } from "@apollo/query-graphs"; import { stripIgnoredCharacters, print, OperationTypeNode, SelectionSetNode, Kind } from "graphql"; -import { DeferredNode, FetchDataRewrite } from "."; +import { DeferredNode, FetchDataRewrite, FetchDataValueSetter } from "."; import { Conditions, conditionsOfSelectionSet, isConstantCondition, mergeConditions, removeConditionsFromSelectionSet, updatedConditions } from "./conditions"; import { enforceQueryPlannerConfigDefaults, QueryPlannerConfig, validateQueryPlannerConfig } from "./config"; import { generateAllPlansAndFindBest } from "./generateAllPlans"; @@ -1181,7 +1182,11 @@ class FetchGroup { this.inputs?.addAll(other.inputs); if (other.inputRewrites) { - other.inputRewrites.forEach((r) => this.inputRewrites.push(r)); + other.inputRewrites.forEach((r) => { + if (!this.inputRewrites.some((r2) => r2 === r)) { + this.inputRewrites.push(r); + } + }); } } } @@ -1629,6 +1634,35 @@ type FieldToAlias = { alias: string, } +function createPathFromSelection(selection: Selection): string[] { + const path: string[] = []; + + const helper = (sel: Selection) => { + if (sel.kind === 'FieldSelection') { + path.push(sel.element.name); + } else if (sel.kind === 'FragmentSelection') { + path.push(`... ${sel.element.typeCondition ? sel.element.typeCondition.name : ''}`); + } else { + assertUnreachable(sel); + } + const ss = sel.selectionSet; + if (ss && ss.selections().length > 0) { + helper(ss.selections()[0]); + } + }; + + helper(selection); + return path; +} + +function selectionAsFetchDataValueSetter(selection: Selection, alias: string): FetchDataValueSetter { + return { + kind: 'ValueSetter', + path: createPathFromSelection(selection), + setValueTo: alias, + } +} + function computeAliasesForNonMergingFields(selections: SelectionSetAtPath[], aliasCollector: FieldToAlias[]) { const seenResponseNames = new Map(); const rebasedFieldsInSet = (s: SelectionSetAtPath) => ( @@ -4177,7 +4211,7 @@ function computeGroupsForTree( updated.group.contextSelections = new Map(); } for (const [key, value] of tree.contextToSelection) { - updated.group.contextSelections.set(key, value); + updated.group.contextSelections.set(key, value); } } updateCreatedGroups(createdGroups, ...requireResult.createdGroups); @@ -4252,7 +4286,18 @@ function computeGroupsForTree( wrapInputsSelections(inputType, inputSelections.get(), context), // TODO: is the context right computeInputRewritesOnKeyFetch(inputType.name, type), ); - + + for (const [key, value] of group.contextSelections ?? []) { + const it = dependencyGraph.typeForFetchInputs(value.parentType.name); + const selections = newCompositeTypeSelectionSet(it); + selections.updates().add(value.selections()); + + newGroup.addInputs( + wrapInputsSelections(it, selections.get(), context), + [selectionAsFetchDataValueSetter(selections.get().selections()[1], key)] + ); + } + updateCreatedGroups(createdGroups, newGroup); // TODO: There is currently a problem where we are getting the current field in the previous fetch group, where From b2bf2b6a64192fd8757200d834e0c7bc217c5c29 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 26 Apr 2024 09:48:57 -0500 Subject: [PATCH 09/82] Make context variable definition deterministic rather than random --- package-lock.json | 18 ------------------ query-graphs-js/package.json | 1 - query-graphs-js/src/graphPath.ts | 14 ++++++-------- query-graphs-js/src/querygraph.ts | 5 +++-- 4 files changed, 9 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4abc8d347..750c14322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13631,23 +13631,6 @@ "license": "MIT", "optional": true }, - "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/nanomatch": { "version": "1.2.13", "dev": true, @@ -17849,7 +17832,6 @@ "dependencies": { "@apollo/federation-internals": "2.7.2", "deep-equal": "^2.0.5", - "nanoid": "^3.3.6", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" }, diff --git a/query-graphs-js/package.json b/query-graphs-js/package.json index bf960dea3..68fce655a 100644 --- a/query-graphs-js/package.json +++ b/query-graphs-js/package.json @@ -25,7 +25,6 @@ "dependencies": { "@apollo/federation-internals": "2.7.2", "deep-equal": "^2.0.5", - "nanoid": "^3.3.6", "ts-graphviz": "^1.5.4", "uuid": "^9.0.0" }, diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 8a6e6c9a7..78116a703 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -36,9 +36,6 @@ import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRoo import { DownCast, Transition } from "./transition"; import { PathContext, emptyContext } from "./pathContext"; import { v4 as uuidv4 } from 'uuid'; -import { customAlphabet } from 'nanoid'; - -const idGen = customAlphabet('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); const debug = newDebugLogger('path'); @@ -589,8 +586,8 @@ export class GraphPath(); } - contextToSelection[idx]?.set(entry.uuid, entry.selectionSet); - parameterToContext[parameterToContext.length-1]?.set(entry.paramName, entry.uuid); + contextToSelection[idx]?.set(entry.id, entry.selectionSet); + parameterToContext[parameterToContext.length-1]?.set(entry.paramName, entry.id); } return { edgeConditions, @@ -966,7 +963,7 @@ type ContextMapEntry = { selectionSet: SelectionSet, inboundEdge: Edge, paramName: string, - uuid: string, // a random string because a single context might have different values depending on usage + id: string, } export type ConditionResolution = { @@ -1903,8 +1900,9 @@ function canSatisfyConditions `Expected edge to be a FieldCollection edge, got ${edge.transition.kind}`); + const id = `${cxt.subgraphName}_${edge.head.type.name}_${edge.transition.definition.name}_${cxt.namedParameter}`; + contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; if (resolution.cost === -1 || totalCost === -1) { totalCost = -1; diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index 993727c46..d4634e22c 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -126,6 +126,7 @@ export interface OverrideCondition { export type ContextCondition = { context: string; + subgraphName: string; namedParameter: string; selection: string; typesWithContextSet: Set; @@ -906,9 +907,9 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr assert(typesWithContextSet, () => `Context ${context} is never set in subgraph`); const z = coordinateMap.get(fieldCoordinate); if (z) { - z.push({ namedParameter, context, selection, typesWithContextSet }); + z.push({ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name }); } else { - coordinateMap.set(fieldCoordinate, [{ namedParameter, context, selection, typesWithContextSet }]); + coordinateMap.set(fieldCoordinate, [{ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name }]); } } From 1bc21d182cfe7b5166d26c4ff4b33ea76e415768 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 26 Apr 2024 11:08:56 -0500 Subject: [PATCH 10/82] fixed up tests with a todo --- .../src/__tests__/buildPlan.test.ts | 92 ++++++++++--------- query-planner-js/src/buildPlan.ts | 12 +-- 2 files changed, 57 insertions(+), 47 deletions(-) diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 9b080984d..8001652a0 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8407,6 +8407,7 @@ describe('@fromContext impacts on query planning', () => { type U @key(fields: "id") { id: ID! + b: String! field(a: String! @fromContext(field: "$context { prop }")): Int! } `, @@ -8451,6 +8452,7 @@ describe('@fromContext impacts on query planning', () => { { t { u { + b field } } @@ -8479,11 +8481,16 @@ describe('@fromContext impacts on query planning', () => { __typename id } + ... on T { + __typename + prop + } } => { ... on U { id - field + b + field(a: "$Subgraph1_U_field_a") } } }, @@ -8492,7 +8499,7 @@ describe('@fromContext impacts on query planning', () => { } `); }); - + it('fromContext variable is from different subgraph', () => { const subgraph1 = { name: 'Subgraph1', @@ -8522,7 +8529,7 @@ describe('@fromContext impacts on query planning', () => { type Query { a: Int! } - + type T @key(fields: "id") { id: ID! prop: String! @@ -8575,12 +8582,42 @@ describe('@fromContext impacts on query planning', () => { Fetch(service: "Subgraph1") { { t { - prop - u { + __typename + id + } + } + }, + Flatten(path: "t") { + Fetch(service: "Subgraph2") { + { + ... on T { + __typename id } + } => + { + ... on T { + prop + } } - } + }, + }, + Flatten(path: "t") { + Fetch(service: "Subgraph1") { + { + ... on T { + __typename + id + } + } => + { + ... on T { + u { + id + } + } + } + }, }, Flatten(path: "t.u") { Fetch(service: "Subgraph1") { @@ -8589,11 +8626,15 @@ describe('@fromContext impacts on query planning', () => { __typename id } + ... on T { + __typename + prop + } } => { ... on U { id - field + field(a: "$Subgraph1_U_field_a") } } }, @@ -8602,8 +8643,8 @@ describe('@fromContext impacts on query planning', () => { } `); }); - - it('fromContext variable is a list', () => { + + it.skip('fromContext variable is a list', () => { const subgraph1 = { name: 'Subgraph1', url: 'https://Subgraph1', @@ -8672,37 +8713,6 @@ describe('@fromContext impacts on query planning', () => { ); const plan = queryPlanner.buildQueryPlan(operation); - expect(plan).toMatchInlineSnapshot(` - QueryPlan { - Sequence { - Fetch(service: "Subgraph1") { - { - t { - prop - u { - id - } - } - } - }, - Flatten(path: "t.u") { - Fetch(service: "Subgraph1") { - { - ... on U { - __typename - id - } - } => - { - ... on U { - id - field - } - } - }, - }, - }, - } - `); + expect(plan).toMatchInlineSnapshot(``); }); }); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 2ff9de1eb..9c407ccd5 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -4300,12 +4300,12 @@ function computeGroupsForTree( updateCreatedGroups(createdGroups, newGroup); - // TODO: There is currently a problem where we are getting the current field in the previous fetch group, where - // we really want to get the condition only. To be fixed. - if (conditions) { - stack.push(updated); - } - + // TODO: + // partition the tree into children with and without triggers. The children with triggers should be processed in the + // child FetchGroup, and those without in the parent. + // if (conditions) { + // stack.push(updated); + // } stack.push({ tree, group: newGroup, From ed116b106b44f96197a91ddad51f2f27fec69b0b Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 26 Apr 2024 11:54:34 -0500 Subject: [PATCH 11/82] Fix up spelling and formatting --- composition-js/src/__tests__/compose.setContext.test.ts | 8 ++++---- query-graphs-js/src/querygraph.ts | 2 +- query-planner-js/src/buildPlan.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index b4f841172..f9143909d 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -298,7 +298,7 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! field ( - a: String! @fromContext(field: "$nocontext { prop }") + a: String! @fromContext(field: "$unknown { prop }") ): Int! } ` @@ -321,7 +321,7 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"nocontext\" is used at location \"U.field(a:)\" but is never set.'); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"unknown\" is used at location \"U.field(a:)\" but is never set.'); }); it('resolved field is not available in context', () => { @@ -652,7 +652,7 @@ describe('setContext tests', () => { assertCompositionSuccess(result); }); - it('type condition on union, but a member of the union doesnt have property', () => { + it("type condition on union, but a member of the union doesn't have property", () => { const subgraph1 = { name: 'Subgraph1', utl: 'https://Subgraph1', @@ -880,7 +880,7 @@ describe('setContext tests', () => { assertCompositionSuccess(result); }); - // Since it's possible that we have to call into the same subggraph with multiple fetch groups where we would have previously used only one, + // Since it's possible that we have to call into the same subgraph with multiple fetch groups where we would have previously used only one, // we need to verify that there is a resolvable key on the object that uses a context. it('at least one key on an object that uses a context must be resolvable', () => { const subgraph1 = { diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index d4634e22c..2a142eb44 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -192,7 +192,7 @@ export class Edge { /** * Potentially multiple context conditions. When @fromContext is used on a argument definition, the edge connecting the type to the - * argument needs to reflect that the condition must be satisifed in order for the edge to be taken + * argument needs to reflect that the condition must be satisfied in order for the edge to be taken */ requiredContexts?: ContextCondition[], ) { diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 9c407ccd5..32f13a8ab 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -4205,7 +4205,7 @@ function computeGroupsForTree( updated.path = requireResult.path; if (tree.contextToSelection) { - // each of the selections that could be used in a @fromContext paramter should be saved to the fetch group. + // each of the selections that could be used in a @fromContext parameter should be saved to the fetch group. // This will also be important in determining when it is necessary to draw a new fetch group boundary if (updated.group.contextSelections === undefined) { updated.group.contextSelections = new Map(); From e6503b51a7f38ac721d25109b9bc6623ba1b9cd6 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 29 Apr 2024 08:10:40 -0500 Subject: [PATCH 12/82] Treat argument to contextual fields as a variable rather than a string --- internals-js/src/federation.ts | 2 +- internals-js/src/operations.ts | 8 +- query-graphs-js/src/graphPath.ts | 4 +- .../src/__tests__/buildPlan.test.ts | 114 +++++++++++++++++- query-planner-js/src/buildPlan.ts | 8 ++ 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 636de035f..828e1a76b 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -516,7 +516,7 @@ function canFulfillType(requestedType: NamedType | InputType, fulfillingType: Na if (requestedType.toString() === fulfillingType.toString()) { return true; } - if (isWrapperType(requestedType) && isWrapperType(fulfillingType)) { + if (isWrapperType(requestedType) && isWrapperType(fulfillingType) && (requestedType.kind === fulfillingType.kind)) { return canFulfillType(requestedType.baseType(), fulfillingType.baseType()); } if (!isNonNullType(requestedType) && isNonNullType(fulfillingType)) { diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index 78c56093e..4257dd5b2 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -289,15 +289,17 @@ export class Field ex // We need to make sure the field has valid values for every non-optional argument. for (const argDef of this.definition.arguments()) { const appliedValue = this.argumentValue(argDef.name); + + // TODO: This is a hack that will not work if directives are renamed. Not sure how to fix as we're missing metadata + const isContextualArg = !!argDef.appliedDirectives.find(d => d.name === 'federation__fromContext'); + if (appliedValue === undefined) { - // TODO: This is a hack that will not work if directives are renamed. Not sure how to fix as we're missing metadata - const isContextualArg = !!argDef.appliedDirectives.find(d => d.name === 'federation__fromContext'); validate( argDef.defaultValue !== undefined || isNullableType(argDef.type!) || isContextualArg, () => `Missing mandatory value for argument "${argDef.name}" of field "${this.definition.coordinate}" in selection "${this}"`); } else { validate( - isValidValue(appliedValue, argDef, variableDefinitions), + isValidValue(appliedValue, argDef, variableDefinitions) || isContextualArg, () => `Invalid value ${valueToString(appliedValue)} for argument "${argDef.coordinate}" of type ${argDef.type}`) } } diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 78116a703..0e39a5290 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -30,6 +30,7 @@ import { isInterfaceType, isSubset, parseSelectionSet, + Variable, } from "@apollo/federation-internals"; import { OpPathTree, traversePathTree } from "./pathTree"; import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRootType, FEDERATED_GRAPH_ROOT_SOURCE } from "./querygraph"; @@ -525,7 +526,7 @@ export class GraphPath { - acc[key] = `$${value}`; + acc[key] = new Variable(value); return acc; }, {}); newTrigger = (trigger as Field).withUpdatedArguments(args) as TTrigger; @@ -571,7 +572,6 @@ export class GraphPath(); for (const [_, entry] of conditionsResolution.contextMap) { diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 8001652a0..38873800f 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8490,7 +8490,7 @@ describe('@fromContext impacts on query planning', () => { ... on U { id b - field(a: "$Subgraph1_U_field_a") + field(a: $Subgraph1_U_field_a) } } }, @@ -8634,7 +8634,7 @@ describe('@fromContext impacts on query planning', () => { { ... on U { id - field(a: "$Subgraph1_U_field_a") + field(a: $Subgraph1_U_field_a) } } }, @@ -8715,4 +8715,114 @@ describe('@fromContext impacts on query planning', () => { const plan = queryPlanner.buildQueryPlan(operation); expect(plan).toMatchInlineSnapshot(``); }); + + it('fromContext fetched as a list', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: [T]! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + b: String! + field(a: String! @fromContext(field: "$context { prop }")): Int! + } + `, + }; + + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + `, + }; + + const asFed2Service = (service: ServiceDefinition) => { + return { + ...service, + typeDefs: asFed2SubgraphDocument(service.typeDefs, { + includeAllImports: true, + }), + }; + }; + + const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { + return composeServices(services.map((s) => asFed2Service(s))); + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + const [api, queryPlanner] = [ + result.schema!.toAPISchema(), + new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + ]; + // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); + const operation = operationFromDocument( + api, + gql` + { + t { + u { + b + field + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + prop + u { + id + } + } + } + }, + Flatten(path: "t.@.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + ... on T { + __typename + prop + } + } => + { + ... on U { + id + b + field(a: $Subgraph1_U_field_a) + } + } + }, + }, + }, + } + `); + }); }); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 32f13a8ab..d85044e10 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1539,6 +1539,14 @@ class FetchGroup { if (this.selection.isEmpty()) { return undefined; } + + // for all contextual arguments, the values will be provided as an inputRewrite rather than in the variableDefintions. + // Note that it won't match the actual type, so we just use Int here as a placeholder + for (const context of this.inputs?.usedContexts ?? []) { + const intType = this.dependencyGraph.supergraphSchema.type('Int')!; + assert(intType.kind === 'ScalarType', () => `Expected ${s} to be a scalar type`); + variableDefinitions.add(new VariableDefinition(this.dependencyGraph.supergraphSchema, new Variable(context), intType)); + } const { selection, outputRewrites } = this.finalizeSelection(variableDefinitions, handledConditions); From 7af595268ec7d704cd7e3072c7f83ae3165215dd Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 29 Apr 2024 08:10:59 -0500 Subject: [PATCH 13/82] typo --- query-planner-js/src/buildPlan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index d85044e10..254788967 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1544,7 +1544,7 @@ class FetchGroup { // Note that it won't match the actual type, so we just use Int here as a placeholder for (const context of this.inputs?.usedContexts ?? []) { const intType = this.dependencyGraph.supergraphSchema.type('Int')!; - assert(intType.kind === 'ScalarType', () => `Expected ${s} to be a scalar type`); + assert(intType.kind === 'ScalarType', () => `Expected ${intType} to be a scalar type`); variableDefinitions.add(new VariableDefinition(this.dependencyGraph.supergraphSchema, new Variable(context), intType)); } From 86b9120ebb390245e30fe14ca0daf761e9a0fac1 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 29 Apr 2024 08:42:31 -0500 Subject: [PATCH 14/82] test --- query-graphs-js/src/graphPath.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 0e39a5290..fc6b4e943 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1901,7 +1901,7 @@ function canSatisfyConditions `Expected edge to be a FieldCollection edge, got ${edge.transition.kind}`); - const id = `${cxt.subgraphName}_${edge.head.type.name}_${edge.transition.definition.name}_${cxt.namedParameter}`; + const id = `${cxt.subgraphName}_${edge.head.type.name}_${edge.transition.definition.name}_${cxt.namedParameter}_1`; contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; if (resolution.cost === -1 || totalCost === -1) { From 0ba353bfd09f88d9025e735f2d9a5cd1ebc6922b Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 29 Apr 2024 08:52:44 -0500 Subject: [PATCH 15/82] testing in codesandbox, ignore this commit --- query-graphs-js/src/graphPath.ts | 2 +- query-planner-js/src/buildPlan.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index fc6b4e943..0e39a5290 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1901,7 +1901,7 @@ function canSatisfyConditions `Expected edge to be a FieldCollection edge, got ${edge.transition.kind}`); - const id = `${cxt.subgraphName}_${edge.head.type.name}_${edge.transition.definition.name}_${cxt.namedParameter}_1`; + const id = `${cxt.subgraphName}_${edge.head.type.name}_${edge.transition.definition.name}_${cxt.namedParameter}`; contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; if (resolution.cost === -1 || totalCost === -1) { diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 254788967..3c43efafe 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1541,11 +1541,11 @@ class FetchGroup { } // for all contextual arguments, the values will be provided as an inputRewrite rather than in the variableDefintions. - // Note that it won't match the actual type, so we just use Int here as a placeholder + // Note that it won't match the actual type, so we just use String! here as a placeholder for (const context of this.inputs?.usedContexts ?? []) { - const intType = this.dependencyGraph.supergraphSchema.type('Int')!; - assert(intType.kind === 'ScalarType', () => `Expected ${intType} to be a scalar type`); - variableDefinitions.add(new VariableDefinition(this.dependencyGraph.supergraphSchema, new Variable(context), intType)); + const stringType = this.dependencyGraph.supergraphSchema.type('String')!; + assert(stringType.kind === 'ScalarType', () => `Expected ${stringType} to be a scalar type`); + variableDefinitions.add(new VariableDefinition(this.dependencyGraph.supergraphSchema, new Variable(context), new NonNullType(stringType))); } const { selection, outputRewrites } = this.finalizeSelection(variableDefinitions, handledConditions); From b8e908579c515e66a495fdf614783ea0dee35ff7 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 29 Apr 2024 10:57:26 -0500 Subject: [PATCH 16/82] trying to use a key renamer instead of a value setter --- query-planner-js/src/buildPlan.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 3c43efafe..a94d068f2 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -97,7 +97,7 @@ import { FEDERATED_GRAPH_ROOT_SOURCE, } from "@apollo/query-graphs"; import { stripIgnoredCharacters, print, OperationTypeNode, SelectionSetNode, Kind } from "graphql"; -import { DeferredNode, FetchDataRewrite, FetchDataValueSetter } from "."; +import { DeferredNode, FetchDataKeyRenamer, FetchDataRewrite } from "."; import { Conditions, conditionsOfSelectionSet, isConstantCondition, mergeConditions, removeConditionsFromSelectionSet, updatedConditions } from "./conditions"; import { enforceQueryPlannerConfigDefaults, QueryPlannerConfig, validateQueryPlannerConfig } from "./config"; import { generateAllPlansAndFindBest } from "./generateAllPlans"; @@ -1663,11 +1663,11 @@ function createPathFromSelection(selection: Selection): string[] { return path; } -function selectionAsFetchDataValueSetter(selection: Selection, alias: string): FetchDataValueSetter { +function selectionAsFetchDataValueSetter(selection: Selection, alias: string): FetchDataKeyRenamer { return { - kind: 'ValueSetter', + kind: 'KeyRenamer', path: createPathFromSelection(selection), - setValueTo: alias, + renameKeyTo: alias, } } From 411875a98e36e286da9c381a95780efd6dd85ea0 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 29 Apr 2024 16:02:38 -0500 Subject: [PATCH 17/82] Realized that although I do want behavior very much like a inputRewrites, I don't want to use that exactly since it's tied to a requires selection set. Made a new array on FetchNode to rewrite context values. Also note that '..' has a valid meaning to go up a level in the path. --- query-graphs-js/src/graphPath.ts | 24 ++++++++----- query-graphs-js/src/pathTree.ts | 10 +++--- query-planner-js/src/QueryPlan.ts | 3 ++ .../src/__tests__/buildPlan.test.ts | 12 ------- query-planner-js/src/buildPlan.ts | 36 ++++++++++--------- 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 0e39a5290..c5361c2de 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -40,6 +40,12 @@ import { v4 as uuidv4 } from 'uuid'; const debug = newDebugLogger('path'); +export type ContextAtUsageEntry = { + contextId: string, + relativePath: string[], + selectionSet: SelectionSet, +}; + function updateRuntimeTypes(currentRuntimeTypes: readonly ObjectType[], edge: Edge | null): readonly ObjectType[] { if (!edge) { return currentRuntimeTypes; @@ -167,10 +173,10 @@ type PathProps | null)[], /** This parameter is for mapping contexts back to the parameter used to collect the field */ - readonly parameterToContext: readonly (Map | null)[], + readonly parameterToContext: readonly (Map | null)[], } -export class GraphPath implements Iterable<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { +export class GraphPath implements Iterable<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { private constructor( private readonly props: PathProps, ) { @@ -372,7 +378,7 @@ export class GraphPath | null, Map | null]> { + next(): IteratorResult<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { if (this.currentIndex >= path.size) { return { done: true, value: undefined }; } @@ -525,8 +531,8 @@ export class GraphPath { - acc[key] = new Variable(value); + const args = Array.from(lastParameterToContext).reduce((acc: {[key: string]: any}, [key, value]: [string, ContextAtUsageEntry]) => { + acc[key] = new Variable(value.contextId); return acc; }, {}); newTrigger = (trigger as Field).withUpdatedArguments(args) as TTrigger; @@ -558,7 +564,7 @@ export class GraphPath | null)[], - parameterToContext: (Map | null)[], + parameterToContext: (Map | null)[], }{ const edgeConditions = this.props.edgeConditions.concat(conditionsResolution.pathTree ?? null); const contextToSelection = this.props.contextToSelection.concat(null); @@ -572,7 +578,7 @@ export class GraphPath(); + parameterToContext[parameterToContext.length-1] = new Map(); for (const [_, entry] of conditionsResolution.contextMap) { const idx = edgeConditions.length - entry.level -1; @@ -587,7 +593,7 @@ export class GraphPath(); } contextToSelection[idx]?.set(entry.id, entry.selectionSet); - parameterToContext[parameterToContext.length-1]?.set(entry.paramName, entry.id); + parameterToContext[parameterToContext.length-1]?.set(entry.paramName, { contextId: entry.id, relativePath: Array(entry.level).fill(".."), selectionSet: entry.selectionSet } ); } return { edgeConditions, @@ -883,7 +889,7 @@ export class GraphPath extends Iterator<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { +export interface PathIterator extends Iterator<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { currentIndex: number, currentVertex: Vertex } diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index 055970199..457dec8e0 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -1,5 +1,5 @@ import { arrayEquals, assert, copyWitNewLength, mergeMapOrNull, SelectionSet } from "@apollo/federation-internals"; -import { GraphPath, OpGraphPath, OpTrigger, PathIterator } from "./graphPath"; +import { GraphPath, OpGraphPath, OpTrigger, PathIterator, ContextAtUsageEntry } from "./graphPath"; import { Edge, QueryGraph, RootVertex, isRootVertex, Vertex } from "./querygraph"; import { isPathContext } from "./pathContext"; @@ -25,7 +25,7 @@ type Child = { function findTriggerIdx( triggerEquality: (t1: TTrigger, t2: TTrigger) => boolean, - forIndex: [TTrigger, OpPathTree | null, TElements, Map | null, Map | null][] | [TTrigger, OpPathTree | null, TElements][], + forIndex: [TTrigger, OpPathTree | null, TElements, Map | null, Map | null][] | [TTrigger, OpPathTree | null, TElements][], trigger: TTrigger ): number { for (let i = 0; i < forIndex.length; i++) { @@ -49,7 +49,7 @@ export class PathTree boolean, private readonly childs: Child[], readonly contextToSelection: Map | null, - readonly parameterToContext: Map | null, + readonly parameterToContext: Map | null, ) { } @@ -88,7 +88,7 @@ export class PathTree { const maxEdges = graph.outEdgesCount(currentVertex); // We store 'null' edges at `maxEdges` index - const forEdgeIndex: [TTrigger, OpPathTree | null, IterAndSelection[], Map | null, Map | null][][] = new Array(maxEdges + 1); + const forEdgeIndex: [TTrigger, OpPathTree | null, IterAndSelection[], Map | null, Map | null][][] = new Array(maxEdges + 1); const newVertices: Vertex[] = new Array(maxEdges); const order: number[] = new Array(maxEdges + 1); let currentOrder = 0; @@ -133,7 +133,7 @@ export class PathTree | null = null; - let mergedParameterToContext: Map | null = null; + let mergedParameterToContext: Map | null = null; const childs: Child[] = new Array(totalChilds); let idx = 0; diff --git a/query-planner-js/src/QueryPlan.ts b/query-planner-js/src/QueryPlan.ts index 3e2391c13..a658e6d64 100644 --- a/query-planner-js/src/QueryPlan.ts +++ b/query-planner-js/src/QueryPlan.ts @@ -52,6 +52,9 @@ export interface FetchNode { inputRewrites?: FetchDataRewrite[]; // Similar, but for optional "rewrites" to apply to the data that received from a fetch (and before it is applied to the current in-memory results). outputRewrites?: FetchDataRewrite[]; + + // Optional rewrites to apply to the data that is sent as input of this fetch. This is a list of rewrites that should be applied to the data + contextRewrites?: FetchDataKeyRenamer[]; } /** diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 38873800f..19a5118ae 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8481,10 +8481,6 @@ describe('@fromContext impacts on query planning', () => { __typename id } - ... on T { - __typename - prop - } } => { ... on U { @@ -8626,10 +8622,6 @@ describe('@fromContext impacts on query planning', () => { __typename id } - ... on T { - __typename - prop - } } => { ... on U { @@ -8807,10 +8799,6 @@ describe('@fromContext impacts on query planning', () => { __typename id } - ... on T { - __typename - prop - } } => { ... on U { diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index a94d068f2..8017d0db1 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -929,6 +929,7 @@ class FetchGroup { readonly isEntityFetch: boolean, private _selection: MutableSelectionSet<{ conditions: Conditions}>, private _inputs?: GroupInputs, + private _contextInputs?: FetchDataKeyRenamer[], readonly mergeAt?: ResponsePath, readonly deferRef?: string, // Some of the processing on the dependency graph checks for groups to the same subgraph and same mergeAt, and we use this @@ -986,6 +987,7 @@ class FetchGroup { hasInputs, MutableSelectionSet.emptyWithMemoized(parentType, conditionsMemoizer), hasInputs ? new GroupInputs(dependencyGraph.supergraphSchema) : undefined, + undefined, mergeAt, deferRef, hasInputs ? `${toValidGraphQLName(subgraphName)}-${mergeAt?.join('::') ?? ''}` : undefined, @@ -1005,6 +1007,7 @@ class FetchGroup { this.isEntityFetch, this._selection.clone(), this._inputs?.clone(), + this._contextInputs ? this._contextInputs.map((c) => ({ ...c})) : undefined, this.mergeAt, this.deferRef, this.subgraphAndMergeAtKey, @@ -1587,6 +1590,7 @@ class FetchGroup { operationDocumentNode: queryPlannerConfig.exposeDocumentNodeInFetchNode ? operationDocument : undefined, inputRewrites: this.inputRewrites.length === 0 ? undefined : this.inputRewrites, outputRewrites: outputRewrites.length === 0 ? undefined : outputRewrites, + contextRewrites: this._contextInputs, }; return this.isTopLevel @@ -1597,6 +1601,15 @@ class FetchGroup { node: fetchNode, }; } + + addContextRenamer(renamer: FetchDataKeyRenamer) { + if (!this._contextInputs) { + this._contextInputs = []; + } + if (!this._contextInputs.some((c) => c.renameKeyTo === renamer.renameKeyTo)) { + this._contextInputs.push(renamer); + } + } toString(): string { const base = `[${this.index}]${this.deferRef ? '(deferred)' : ''}${this._id ? `{id: ${this._id}}` : ''} ${this.subgraphName}`; @@ -1663,10 +1676,10 @@ function createPathFromSelection(selection: Selection): string[] { return path; } -function selectionAsFetchDataValueSetter(selection: Selection, alias: string): FetchDataKeyRenamer { +function selectionAsKeyRenamer(selection: Selection, relPath: string[], alias: string): FetchDataKeyRenamer { return { kind: 'KeyRenamer', - path: createPathFromSelection(selection), + path: relPath.concat(createPathFromSelection(selection)), renameKeyTo: alias, } } @@ -4267,7 +4280,7 @@ function computeGroupsForTree( // if we're going to start using context variables, every variable used must be set in a different parent // fetch group or else we need to create a new one - if (parameterToContext && groupContextSelections && Array.from(parameterToContext.values()).some(c => groupContextSelections.has(c))) { + if (parameterToContext && groupContextSelections && Array.from(parameterToContext.values()).some(({ contextId }) => groupContextSelections.has(contextId))) { // let's find the edge that will be used as an entry to the new type in the subgraph const entityVertex = dependencyGraph.federatedQueryGraph.verticesForType(edge.head.type.name).find(v => v.source === edge.tail.source); assert(entityVertex, () => `Could not find entity entry edge for ${edge.head.source}`); @@ -4283,8 +4296,10 @@ function computeGroupsForTree( conditionsGroups: [], }); newGroup.addParent({ group, path: path.inGroup() }); - for (const [_, value] of parameterToContext) { - newGroup.addInputContext(value); + for (const [key, { contextId, selectionSet, relativePath }] of parameterToContext) { + newGroup.addInputContext(contextId); + const keyRenamer = selectionAsKeyRenamer(selectionSet.selections()[0], relativePath, key); + newGroup.addContextRenamer(keyRenamer); } const inputType = dependencyGraph.typeForFetchInputs(type.name); @@ -4295,17 +4310,6 @@ function computeGroupsForTree( computeInputRewritesOnKeyFetch(inputType.name, type), ); - for (const [key, value] of group.contextSelections ?? []) { - const it = dependencyGraph.typeForFetchInputs(value.parentType.name); - const selections = newCompositeTypeSelectionSet(it); - selections.updates().add(value.selections()); - - newGroup.addInputs( - wrapInputsSelections(it, selections.get(), context), - [selectionAsFetchDataValueSetter(selections.get().selections()[1], key)] - ); - } - updateCreatedGroups(createdGroups, newGroup); // TODO: From 84c53ef377277024d79df313e60b5cca9ddb8dec Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 29 Apr 2024 16:58:19 -0500 Subject: [PATCH 18/82] Use generated context id rather than paramter name for context_rewrites --- query-planner-js/src/buildPlan.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 8017d0db1..c78602c58 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -4296,9 +4296,9 @@ function computeGroupsForTree( conditionsGroups: [], }); newGroup.addParent({ group, path: path.inGroup() }); - for (const [key, { contextId, selectionSet, relativePath }] of parameterToContext) { + for (const [_, { contextId, selectionSet, relativePath }] of parameterToContext) { newGroup.addInputContext(contextId); - const keyRenamer = selectionAsKeyRenamer(selectionSet.selections()[0], relativePath, key); + const keyRenamer = selectionAsKeyRenamer(selectionSet.selections()[0], relativePath, contextId); newGroup.addContextRenamer(keyRenamer); } From ba7b85d18b27bf281c948a0332d7c0809f071ef2 Mon Sep 17 00:00:00 2001 From: o0Ignition0o Date: Tue, 30 Apr 2024 11:00:42 +0200 Subject: [PATCH 19/82] push an empty commit because package query-graphs wasnt published in csb... From cff5f8d0f357b1d00caefddff63571c4968dee4d Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 30 Apr 2024 08:44:55 -0500 Subject: [PATCH 20/82] Use argument type in validation rather than hardcoded String --- query-graphs-js/src/graphPath.ts | 7 +++++-- query-graphs-js/src/querygraph.ts | 5 +++-- query-planner-js/src/buildPlan.ts | 30 +++++++++++++++--------------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index c5361c2de..201bbb157 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -31,6 +31,7 @@ import { isSubset, parseSelectionSet, Variable, + Type, } from "@apollo/federation-internals"; import { OpPathTree, traversePathTree } from "./pathTree"; import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRootType, FEDERATED_GRAPH_ROOT_SOURCE } from "./querygraph"; @@ -44,6 +45,7 @@ export type ContextAtUsageEntry = { contextId: string, relativePath: string[], selectionSet: SelectionSet, + subgraphArgType: Type, }; function updateRuntimeTypes(currentRuntimeTypes: readonly ObjectType[], edge: Edge | null): readonly ObjectType[] { @@ -593,7 +595,7 @@ export class GraphPath(); } contextToSelection[idx]?.set(entry.id, entry.selectionSet); - parameterToContext[parameterToContext.length-1]?.set(entry.paramName, { contextId: entry.id, relativePath: Array(entry.level).fill(".."), selectionSet: entry.selectionSet } ); + parameterToContext[parameterToContext.length-1]?.set(entry.paramName, { contextId: entry.id, relativePath: Array(entry.level).fill(".."), selectionSet: entry.selectionSet, subgraphArgType: entry.argType } ); } return { edgeConditions, @@ -969,6 +971,7 @@ type ContextMapEntry = { selectionSet: SelectionSet, inboundEdge: Edge, paramName: string, + argType: Type, id: string, } @@ -1908,7 +1911,7 @@ function canSatisfyConditions `Expected edge to be a FieldCollection edge, got ${edge.transition.kind}`); const id = `${cxt.subgraphName}_${edge.head.type.name}_${edge.transition.definition.name}_${cxt.namedParameter}`; - contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id }); + contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id, argType: cxt.argType }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; if (resolution.cost === -1 || totalCost === -1) { totalCost = -1; diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index 2a142eb44..08e75f4e9 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -130,6 +130,7 @@ export type ContextCondition = { namedParameter: string; selection: string; typesWithContextSet: Set; + argType: Type, } /** @@ -907,9 +908,9 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr assert(typesWithContextSet, () => `Context ${context} is never set in subgraph`); const z = coordinateMap.get(fieldCoordinate); if (z) { - z.push({ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name }); + z.push({ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name, argType: application.parent.type }); } else { - coordinateMap.set(fieldCoordinate, [{ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name }]); + coordinateMap.set(fieldCoordinate, [{ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name, argType: application.parent.type }]); } } diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index c78602c58..b34bdce27 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -62,6 +62,7 @@ import { Supergraph, sameType, assertUnreachable, + isInputType, } from "@apollo/federation-internals"; import { advanceSimultaneousPathsWithOperation, @@ -793,7 +794,7 @@ type ParentRelation = { const conditionsMemoizer = (selectionSet: SelectionSet) => ({ conditions: conditionsOfSelectionSet(selectionSet) }); class GroupInputs { - readonly usedContexts = new Set; + readonly usedContexts = new Map; private readonly perType = new Map(); onUpdateCallback?: () => void | undefined = undefined; @@ -815,8 +816,8 @@ class GroupInputs { this.onUpdateCallback?.(); } - addContext(context: string) { - this.usedContexts.add(context); + addContext(context: string, type: Type) { + this.usedContexts.set(context, type); } addAll(other: GroupInputs) { @@ -850,7 +851,7 @@ class GroupInputs { if (this.usedContexts.size < other.usedContexts.size) { return false; } - for (const c of other.usedContexts) { + for (const [c,_] of other.usedContexts) { if (!this.usedContexts.has(c)) { return false; } @@ -872,7 +873,7 @@ class GroupInputs { if (this.usedContexts.size !== other.usedContexts.size) { return false; } - for (const c of other.usedContexts) { + for (const [c,_] of other.usedContexts) { if (!this.usedContexts.has(c)) { return false; } @@ -885,8 +886,8 @@ class GroupInputs { for (const [type, selection] of this.perType.entries()) { cloned.perType.set(type, selection.clone()); } - for (const c of this.usedContexts) { - cloned.usedContexts.add(c); + for (const [c,v] of this.usedContexts) { + cloned.usedContexts.set(c,v); } return cloned; } @@ -1174,10 +1175,10 @@ class FetchGroup { } } - addInputContext(context: string) { + addInputContext(context: string, type: Type) { assert(this._inputs, "Shouldn't try to add inputs to a root fetch group"); - this._inputs.addContext(context); + this._inputs.addContext(context, type); } copyInputsOf(other: FetchGroup) { @@ -1545,10 +1546,9 @@ class FetchGroup { // for all contextual arguments, the values will be provided as an inputRewrite rather than in the variableDefintions. // Note that it won't match the actual type, so we just use String! here as a placeholder - for (const context of this.inputs?.usedContexts ?? []) { - const stringType = this.dependencyGraph.supergraphSchema.type('String')!; - assert(stringType.kind === 'ScalarType', () => `Expected ${stringType} to be a scalar type`); - variableDefinitions.add(new VariableDefinition(this.dependencyGraph.supergraphSchema, new Variable(context), new NonNullType(stringType))); + for (const [context, type] of this.inputs?.usedContexts ?? []) { + assert(isInputType(type), () => `Expected ${type} to be a input type`); + variableDefinitions.add(new VariableDefinition(this.dependencyGraph.supergraphSchema, new Variable(context), type)); } const { selection, outputRewrites } = this.finalizeSelection(variableDefinitions, handledConditions); @@ -4296,8 +4296,8 @@ function computeGroupsForTree( conditionsGroups: [], }); newGroup.addParent({ group, path: path.inGroup() }); - for (const [_, { contextId, selectionSet, relativePath }] of parameterToContext) { - newGroup.addInputContext(contextId); + for (const [_, { contextId, selectionSet, relativePath, subgraphArgType }] of parameterToContext) { + newGroup.addInputContext(contextId, subgraphArgType); const keyRenamer = selectionAsKeyRenamer(selectionSet.selections()[0], relativePath, contextId); newGroup.addContextRenamer(keyRenamer); } From e692a8198815807339ef3604208ca2fab278daea Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 30 Apr 2024 10:59:55 -0500 Subject: [PATCH 21/82] Add typename to query plan --- query-graphs-js/src/pathTree.ts | 2 +- query-planner-js/src/__tests__/buildPlan.test.ts | 7 ++++++- query-planner-js/src/buildPlan.ts | 14 ++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index 457dec8e0..d5d9b9b2d 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -49,7 +49,7 @@ export class PathTree boolean, private readonly childs: Child[], readonly contextToSelection: Map | null, - readonly parameterToContext: Map | null, + public parameterToContext: Map | null, ) { } diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 19a5118ae..299e6c590 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8469,7 +8469,9 @@ describe('@fromContext impacts on query planning', () => { t { prop u { + __typename id + b } } } @@ -8609,6 +8611,7 @@ describe('@fromContext impacts on query planning', () => { { ... on T { u { + __typename id } } @@ -8707,7 +8710,7 @@ describe('@fromContext impacts on query planning', () => { const plan = queryPlanner.buildQueryPlan(operation); expect(plan).toMatchInlineSnapshot(``); }); - + it('fromContext fetched as a list', () => { const subgraph1 = { name: 'Subgraph1', @@ -8787,7 +8790,9 @@ describe('@fromContext impacts on query planning', () => { t { prop u { + __typename id + b } } } diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index b34bdce27..2c9158dd6 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1192,6 +1192,12 @@ class FetchGroup { } }); } + if (other._contextInputs) { + if (!this._contextInputs) { + this._contextInputs = []; + } + this._contextInputs.push(...other._contextInputs); + } } } @@ -4137,7 +4143,7 @@ function computeGroupsForTree( mergeAt: path.inResponse(), deferRef: updatedDeferContext.activeDeferRef, }); - newGroup.addParent({ group, path: path.inGroup() }); + newGroup.addParent({ group, path: path.inGroup() }); stack.push({ tree: child, group: newGroup, @@ -4295,13 +4301,17 @@ function computeGroupsForTree( parent: { group, path: path.inGroup() }, conditionsGroups: [], }); + newGroup.addParent({ group, path: path.inGroup() }); for (const [_, { contextId, selectionSet, relativePath, subgraphArgType }] of parameterToContext) { newGroup.addInputContext(contextId, subgraphArgType); const keyRenamer = selectionAsKeyRenamer(selectionSet.selections()[0], relativePath, contextId); newGroup.addContextRenamer(keyRenamer); } - + tree.parameterToContext = null; + // We also ensure to get the __typename of the current type in the "original" group. + group.addAtPath(path.inGroup().concat(new Field(type.typenameField()!))); + const inputType = dependencyGraph.typeForFetchInputs(type.name); const inputSelections = newCompositeTypeSelectionSet(inputType); inputSelections.updates().add(keyResolutionEdge.conditions!); From 509674d783742ee87923e6d1708511aceba635c0 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 30 Apr 2024 11:18:25 -0500 Subject: [PATCH 22/82] fix bug in copyInputsOf --- query-planner-js/src/buildPlan.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 2c9158dd6..189adabaf 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1196,7 +1196,11 @@ class FetchGroup { if (!this._contextInputs) { this._contextInputs = []; } - this._contextInputs.push(...other._contextInputs); + other._contextInputs.forEach((r) => { + if (!this._contextInputs!.some((r2) => r2 === r)) { + this._contextInputs!.push(r); + } + }); } } } From e30ff814fbc6a738ab34476591adc5b0f71f3902 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 5 May 2024 22:08:43 -0500 Subject: [PATCH 23/82] address comment --- internals-js/src/specs/contextSpec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index f6f649c51..99edff3b1 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -31,8 +31,6 @@ export class ContextSpecDefinition extends FeatureDefinition { ) ); - this.registerType(createScalarTypeSpecification({ name: ContextDirectiveName.CONTEXT })); - this.contextDirectiveSpec = createDirectiveSpecification({ name: ContextDirectiveName.CONTEXT, locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT, DirectiveLocation.UNION], From 3d352253b3155a640b99b7e14d770444ef309629 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 5 May 2024 22:27:01 -0500 Subject: [PATCH 24/82] more changes --- composition-js/src/composeDirectiveManager.ts | 1 + internals-js/src/federation.ts | 44 +++++++++---------- internals-js/src/specs/contextSpec.ts | 2 +- internals-js/src/specs/federationSpec.ts | 6 --- internals-js/src/specs/joinSpec.ts | 2 + 5 files changed, 25 insertions(+), 30 deletions(-) diff --git a/composition-js/src/composeDirectiveManager.ts b/composition-js/src/composeDirectiveManager.ts index 816e25354..e2fe7b170 100644 --- a/composition-js/src/composeDirectiveManager.ts +++ b/composition-js/src/composeDirectiveManager.ts @@ -65,6 +65,7 @@ const DISALLOWED_IDENTITIES = [ 'https://specs.apollo.dev/authenticated', 'https://specs.apollo.dev/requiresScopes', 'https://specs.apollo.dev/source', + 'https://specs.apollo.dev/context', ]; export class ComposeDirectiveManager { diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 828e1a76b..b648cc366 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1495,32 +1495,30 @@ export class FederationBlueprint extends SchemaBlueprint { } const fromContextDirective = metadata.fromContextDirective(); - if (isFederationDirectiveDefinedInSchema(fromContextDirective)) { - for (const application of fromContextDirective.applications()) { - const { field } = application.arguments(); - const { context, selection } = parseContext(field); - const parent = application.parent as ArgumentDefinition>; - if (!context || !selection) { - errorCollector.push(ERRORS.NO_CONTEXT_IN_SELECTION.err( - `@fromContext argument does not reference a context "${field}".`, + for (const application of fromContextDirective.applications()) { + const { field } = application.arguments(); + const { context, selection } = parseContext(field); + const parent = application.parent as ArgumentDefinition>; + if (!context || !selection) { + errorCollector.push(ERRORS.NO_CONTEXT_IN_SELECTION.err( + `@fromContext argument does not reference a context "${field}".`, + { nodes: sourceASTs(application) } + )); + } else { + const locations = contextToTypeMap.get(context); + if (!locations) { + errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( + `Context "${context}" is used at location "${parent.coordinate}" but is never set.`, { nodes: sourceASTs(application) } )); } else { - const locations = contextToTypeMap.get(context); - if (!locations) { - errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( - `Context "${context}" is used at location "${parent.coordinate}" but is never set.`, - { nodes: sourceASTs(application) } - )); - } else { - validateFieldValue({ - context, - selection, - fromContextParent: parent, - setContextLocations: locations, - errorCollector, - }); - } + validateFieldValue({ + context, + selection, + fromContextParent: parent, + setContextLocations: locations, + errorCollector, + }); } // validate that there is at least one resolvable key on the type diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index 99edff3b1..8b1b298f5 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -7,7 +7,7 @@ import { FeatureVersion, } from "./coreSpec"; import { NonNullType } from "../definitions"; -import { DirectiveSpecification, createDirectiveSpecification, createScalarTypeSpecification } from "../directiveAndTypeSpecification"; +import { DirectiveSpecification, createDirectiveSpecification } from "../directiveAndTypeSpecification"; import { registerKnownFeature } from "../knownCoreFeatures"; import { Subgraph } from '../federation'; diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 062c73eb1..51e0d4a25 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -112,12 +112,6 @@ function fieldSetType(schema: Schema): InputType { return new NonNullType(metadata.fieldSetType()); } -// function fieldValueType(schema: Schema): InputType { -// const metadata = federationMetadata(schema); -// assert(metadata, `The schema is not a federation subgraph`); -// return new NonNullType(metadata.fieldValueType()); -// } - export class FederationSpecDefinition extends FeatureDefinition { constructor(version: FeatureVersion) { super(new FeatureUrl(federationIdentity, 'federation', version)); diff --git a/internals-js/src/specs/joinSpec.ts b/internals-js/src/specs/joinSpec.ts index 77737affa..1a064d65b 100644 --- a/internals-js/src/specs/joinSpec.ts +++ b/internals-js/src/specs/joinSpec.ts @@ -166,6 +166,8 @@ export class JoinSpecDefinition extends FeatureDefinition { const fieldValue = this.addScalarType(schema, 'FieldValue'); // set context + // there are no renames that happen within the join spec, so this is fine + // note that join spec will only used in supergraph schema const requireType = schema.addType(new InputObjectType('join__ContextArgument')); requireType.addField('name', new NonNullType(schema.stringType())); requireType.addField('type', new NonNullType(schema.stringType())); From d370f20e4a2f54b314a19009b038dc0c7b2cfd3b Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 5 May 2024 22:28:56 -0500 Subject: [PATCH 25/82] updating regex --- internals-js/src/federation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index b648cc366..f99f57df6 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -343,7 +343,7 @@ function fieldSetTargetDescription(directive: Directive): st } export function parseContext(input: string) { - const regex = /^\$([\w\d_]+)\s*([\s\S]+)$/; + const regex = /^(?:[\n\r\t ,]|#[^\n\r]*(?![^\n\r]))*\$(?:[\n\r\t ,]|#[^\n\r]*(?![^\n\r]))*([A-Za-z_]\w*(?!\w))([\s\S]*)$/; const match = input.match(regex); if (!match) { return { context: undefined, selection: undefined }; From 52c043f910906a9dee2ffa13f5f5e14dbd4db03d Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 5 May 2024 22:48:01 -0500 Subject: [PATCH 26/82] get rid of canFulfillType to make more standard --- internals-js/src/federation.ts | 42 ++++++++++++++-------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index f99f57df6..11acaf04a 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -31,8 +31,9 @@ import { InputType, OutputType, WrapperType, - isWrapperType, isNonNullType, + isLeafType, + isListType, } from "./definitions"; import { assert, MultiMap, printHumanReadableList, OrderedMap, mapValues, assertUnreachable } from "./utils"; import { SDLValidationRule } from "graphql/validation/ValidationContext"; @@ -97,6 +98,7 @@ import { SourceFieldDirectiveArgs, SourceTypeDirectiveArgs, } from "./specs/sourceSpec"; +import { isSubtype } from './types'; const linkSpec = LINK_VERSIONS.latest(); const tagSpec = TAG_VERSIONS.latest(); @@ -394,7 +396,7 @@ const validateFieldValueType = ({ const selections = selectionSet.selections(); assert(selections.length === 1, 'Expected exactly one field to be selected'); - const typesArray = selections.map((selection) => { + const typesArray = selections.map((selection): { resolvedType: InputType | undefined } => { if (selection.kind !== 'FieldSelection') { return { resolvedType: undefined }; } @@ -412,9 +414,8 @@ const validateFieldValueType = ({ } return { resolvedType: wrapResolvedType({ originalType: type, resolvedType}) }; } - assert(type.kind === 'ScalarType' || type.kind === 'EnumType' || (isWrapperType(type) && type.baseType().kind === 'ScalarType'), - 'Expected a scalar or enum type'); - return { resolvedType: type }; + assert(isLeafType(baseType(type)), 'Expected a leaf type'); + return { resolvedType: type as InputType }; }); return typesArray.reduce((acc, { resolvedType }) => { if (acc.resolvedType?.toString() === resolvedType?.toString()) { @@ -503,26 +504,17 @@ const validateSelectionFormat = ({ } } -/** - * Check to see if the requested type can be fulfilled by the fulfilling type. For example, String! can always be used for String - * - */ -function canFulfillType(requestedType: NamedType | InputType, fulfillingType: NamedType | InputType): boolean { - assert(requestedType.kind !== 'ObjectType' - && requestedType.kind !== 'UnionType' && - fulfillingType.kind !== 'ObjectType' && - fulfillingType.kind !== 'UnionType', 'Expected an input type or wrapped input type'); - - if (requestedType.toString() === fulfillingType.toString()) { - return true; - } - if (isWrapperType(requestedType) && isWrapperType(fulfillingType) && (requestedType.kind === fulfillingType.kind)) { - return canFulfillType(requestedType.baseType(), fulfillingType.baseType()); +// implementation of spec https://spec.graphql.org/draft/#IsValidImplementationFieldType() +function isValidImplementationFieldType(fieldType: NamedType | InputType, implementedFieldType: NamedType | InputType): boolean { + if (isNonNullType(fieldType)) { + const nullableType = fieldType.baseType(); + const implementedNullableType = isNonNullType(implementedFieldType) ? implementedFieldType.baseType() : implementedFieldType; + return isValidImplementationFieldType(nullableType, implementedNullableType); } - if (!isNonNullType(requestedType) && isNonNullType(fulfillingType)) { - return canFulfillType(requestedType, fulfillingType.baseType()); + if (isListType(fieldType) && isListType(implementedFieldType)) { + return isValidImplementationFieldType(fieldType.baseType(), implementedFieldType.baseType()); } - return false; + return isSubtype(fieldType, implementedFieldType); } function validateFieldValue({ @@ -570,7 +562,7 @@ function validateFieldValue({ selectionSet, errorCollector, }); - if (resolvedType === undefined || !canFulfillType(expectedType!, resolvedType)) { + if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, { nodes: sourceASTs(fromContextParent) } @@ -617,7 +609,7 @@ function validateFieldValue({ selectionSet: types[0].selectionSet, errorCollector, }); - if (resolvedType === undefined || !canFulfillType(expectedType!, resolvedType)) { + if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, { nodes: sourceASTs(fromContextParent) } From 6514d0b17f02dd2b522149244f1c8dcd5acaac9b Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 6 May 2024 08:53:37 -0500 Subject: [PATCH 27/82] update implementation --- internals-js/src/federation.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 11acaf04a..9b915766f 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -507,9 +507,10 @@ const validateSelectionFormat = ({ // implementation of spec https://spec.graphql.org/draft/#IsValidImplementationFieldType() function isValidImplementationFieldType(fieldType: NamedType | InputType, implementedFieldType: NamedType | InputType): boolean { if (isNonNullType(fieldType)) { - const nullableType = fieldType.baseType(); - const implementedNullableType = isNonNullType(implementedFieldType) ? implementedFieldType.baseType() : implementedFieldType; - return isValidImplementationFieldType(nullableType, implementedNullableType); + if (isNonNullType(implementedFieldType)) { + return isValidImplementationFieldType(fieldType.baseType(), implementedFieldType.baseType()); + } + return false; } if (isListType(fieldType) && isListType(implementedFieldType)) { return isValidImplementationFieldType(fieldType.baseType(), implementedFieldType.baseType()); @@ -694,7 +695,6 @@ export function collectUsedFields(metadata: FederationMetadata): Set( metadata, - type => type, usedFields, ); @@ -716,7 +716,6 @@ export function collectUsedFields(metadata: FederationMetadata): Set>( metadata: FederationMetadata, - targetTypeExtractor: (element: TParent) => CompositeType | undefined, usedFieldDefs: Set> ) { const fromContextDirective = metadata.fromContextDirective(); @@ -730,7 +729,7 @@ function collectUsedFieldsForFromContext // build the list of context entry points const entryPoints = new Map>(); for (const application of contextDirective.applications()) { - const type = targetTypeExtractor(application.parent! as TParent); + const type = application.parent; if (!type) { // Means the application is wrong: we ignore it here as later validation will detect it continue; @@ -739,11 +738,11 @@ function collectUsedFieldsForFromContext if (!entryPoints.has(context)) { entryPoints.set(context, new Set()); } - entryPoints.get(context)!.add(type); + entryPoints.get(context)!.add(type as CompositeType); } for (const application of fromContextDirective.applications()) { - const type = targetTypeExtractor(application.parent! as TParent); + const type = application.parent as TParent; if (!type) { // Means the application is wrong: we ignore it here as later validation will detect it continue; From 9b45fe80c81800c8b109b991b64839effb148837 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 6 May 2024 09:41:24 -0500 Subject: [PATCH 28/82] Add tests for rejecting selection sets with aliases or directives in field values --- .../src/__tests__/compose.setContext.test.ts | 89 +++++++++++++++++++ internals-js/src/federation.ts | 37 +++++++- internals-js/src/operations.ts | 14 +++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index f9143909d..4cd4dc364 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -925,4 +925,93 @@ describe('setContext tests', () => { expect(result.errors?.length).toBe(1); expect(result.errors?.[0].message).toBe('[Subgraph1] Object \"U\" has no resolvable key but has an a field with a contextual argument.'); }); + + it('context selection contains an alias', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { foo: prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: aliases are not allowed in the selection'); + }); + + it('context selection contains a query directive', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + directive @foo on FIELD + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop @foo }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: directives are not allowed in the selection'); + }); }); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 9b915766f..54d77ee57 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -55,7 +55,7 @@ import { } from "graphql"; import { KnownTypeNamesInFederationRule } from "./validation/KnownTypeNamesInFederationRule"; import { buildSchema, buildSchemaFromAST } from "./buildSchema"; -import { FragmentSelection, parseOperationAST, parseSelectionSet, SelectionSet } from './operations'; +import { FragmentSelection, hasSelectionWithPredicate, parseOperationAST, parseSelectionSet, Selection, SelectionSet } from './operations'; import { TAG_VERSIONS } from "./specs/tagSpec"; import { errorCodeDef, @@ -518,6 +518,28 @@ function isValidImplementationFieldType(fieldType: NamedType | InputType, implem return isSubtype(fieldType, implementedFieldType); } +function selectionSetHasDirectives(selectionSet: SelectionSet): boolean { + return hasSelectionWithPredicate(selectionSet, (s: Selection) => { + if (s.kind === 'FieldSelection') { + return s.element.appliedDirectives.length > 0; + } + else if (s.kind === 'FragmentSelection') { + return s.element.appliedDirectives.length > 0; + } else { + assertUnreachable(s); + } + }); +} + +function selectionSetHasAlias(selectionSet: SelectionSet): boolean { + return hasSelectionWithPredicate(selectionSet, (s: Selection) => { + if (s.kind === 'FieldSelection') { + return s.element.alias !== undefined; + } + return false; + }); +} + function validateFieldValue({ context, selection, @@ -557,6 +579,19 @@ function validateFieldValue({ )); return; } + if (selectionSetHasDirectives(selectionSet)) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: directives are not allowed in the selection`, + { nodes: sourceASTs(fromContextParent) } + )); + } + if (selectionSetHasAlias(selectionSet)) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: aliases are not allowed in the selection`, + { nodes: sourceASTs(fromContextParent) } + )); + } + if (selectionType === 'field') { const { resolvedType } = validateFieldValueType({ currentType: explodedType, diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index 75d349062..dfce07398 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -3996,3 +3996,17 @@ export function operationToDocument(operation: Operation): DocumentNode { definitions: [operationAST as DefinitionNode].concat(fragmentASTs), }; } + +export function hasSelectionWithPredicate(selectionSet: SelectionSet, predicate: (s: Selection) => boolean): boolean { + for (const selection of selectionSet.selections()) { + if (predicate(selection)) { + return true; + } + if (selection.selectionSet) { + if (hasSelectionWithPredicate(selection.selectionSet, predicate)) { + return true; + } + } + } + return false; +} From 881ae1ad4c106ed0b7d2204327ff452c1fd64306 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 6 May 2024 10:59:09 -0500 Subject: [PATCH 29/82] Add test for ensuring that no @interfaceObject type may be referenced in a context --- .../src/__tests__/compose.setContext.test.ts | 45 +++++++++++++++++++ internals-js/src/federation.ts | 22 +++++++++ 2 files changed, 67 insertions(+) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 4cd4dc364..b6f5c05b1 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -1014,4 +1014,49 @@ describe('setContext tests', () => { expect(result.errors?.length).toBe(1); expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: directives are not allowed in the selection'); }); + + it('context selection references an @interfaceObject', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + directive @foo on FIELD + type Query { + t: T! + } + + type T @interfaceObject @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"is used in \"U.field(a:)\" but the selection is invalid: One of the types in the selection is an interfaceObject: \"T\"'); + }); }); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 54d77ee57..1e1fa2f3b 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -388,13 +388,26 @@ const validateFieldValueType = ({ currentType, selectionSet, errorCollector, + metadata, + fromContextParent, }: { currentType: CompositeType, selectionSet: SelectionSet, errorCollector: GraphQLError[], + metadata: FederationMetadata, + fromContextParent: ArgumentDefinition>, }): { resolvedType: InputType | undefined } => { const selections = selectionSet.selections(); assert(selections.length === 1, 'Expected exactly one field to be selected'); + + // ensure that type is not an interfaceObject + const interfaceObjectDirective = metadata.interfaceObjectDirective(); + if (currentType.kind === 'ObjectType' && isFederationDirectiveDefinedInSchema(interfaceObjectDirective) && (currentType.appliedDirectivesOf(interfaceObjectDirective).length > 0)) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "is used in "${fromContextParent.coordinate}" but the selection is invalid: One of the types in the selection is an interfaceObject: "${currentType.name}"`, + { nodes: sourceASTs(fromContextParent) } + )); + } const typesArray = selections.map((selection): { resolvedType: InputType | undefined } => { if (selection.kind !== 'FieldSelection') { @@ -408,6 +421,8 @@ const validateFieldValueType = ({ currentType, selectionSet: childSelectionSet, errorCollector, + metadata, + fromContextParent, }); if (!resolvedType) { return { resolvedType: undefined }; @@ -546,12 +561,14 @@ function validateFieldValue({ fromContextParent, setContextLocations, errorCollector, + metadata, } : { context: string, selection: string, fromContextParent: ArgumentDefinition>, setContextLocations: (ObjectType | InterfaceType | UnionType)[], errorCollector: GraphQLError[], + metadata: FederationMetadata, }): void { const expectedType = fromContextParent.type; assert(expectedType, 'Expected a type'); @@ -597,6 +614,8 @@ function validateFieldValue({ currentType: explodedType, selectionSet, errorCollector, + metadata, + fromContextParent, }); if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( @@ -644,6 +663,8 @@ function validateFieldValue({ currentType: explodedType, selectionSet: types[0].selectionSet, errorCollector, + metadata, + fromContextParent, }); if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( @@ -1544,6 +1565,7 @@ export class FederationBlueprint extends SchemaBlueprint { fromContextParent: parent, setContextLocations: locations, errorCollector, + metadata, }); } From 2890817baf83bb098941fdfbc17fd3ccceb9c990 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 7 May 2024 09:28:12 -0500 Subject: [PATCH 30/82] multiple paths --- .../src/__tests__/compose.setContext.test.ts | 56 +------- .../extractSubgraphsFromSupergraph.test.ts | 2 +- internals-js/src/federation.ts | 136 +++++++++--------- internals-js/src/specs/contextSpec.ts | 2 +- .../src/__tests__/buildPlan.test.ts | 3 + query-planner-js/src/buildPlan.ts | 76 ++++++---- 6 files changed, 121 insertions(+), 154 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index b6f5c05b1..a175f904c 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -600,59 +600,7 @@ describe('setContext tests', () => { assertCompositionSuccess(result); }); - it('type condition on union type', () => { - const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', - typeDefs: gql` - type Query { - t: T! - } - - union T @context(name: "context") = T1 | T2 - - type T1 @key(fields: "id") @context(name: "context") { - id: ID! - u: U! - prop: String! - a: String! - } - - type T2 @key(fields: "id") @context(name: "context") { - id: ID! - u: U! - prop: String! - b: String! - } - - type U @key(fields: "id") { - id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! - } - ` - }; - - const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', - typeDefs: gql` - type Query { - a: Int! - } - - type U @key(fields: "id") { - id: ID! - } - ` - }; - - const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); - assertCompositionSuccess(result); - }); - - it("type condition on union, but a member of the union doesn't have property", () => { + it("@context invalid on union", () => { const subgraph1 = { name: 'Subgraph1', utl: 'https://Subgraph1', @@ -702,7 +650,7 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T2. Error: Cannot query field "prop" on type "T2".'); + expect(result.errors?.[0].message).toBe('[Subgraph1] Directive "@context" may not be used on UNION.'); }); it.todo('type mismatch in context variable'); it('nullability mismatch is ok if contextual value is non-nullable', () => { diff --git a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts index 40e95f500..fe62e080c 100644 --- a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts +++ b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts @@ -842,7 +842,7 @@ directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION -directive @context(name: String!) repeatable on INTERFACE | OBJECT | UNION +directive @context(name: String!) repeatable on INTERFACE | OBJECT directive @context__fromContext(field: String) on ARGUMENT_DEFINITION diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 1e1fa2f3b..693635980 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -398,7 +398,6 @@ const validateFieldValueType = ({ fromContextParent: ArgumentDefinition>, }): { resolvedType: InputType | undefined } => { const selections = selectionSet.selections(); - assert(selections.length === 1, 'Expected exactly one field to be selected'); // ensure that type is not an interfaceObject const interfaceObjectDirective = metadata.interfaceObjectDirective(); @@ -566,7 +565,7 @@ function validateFieldValue({ context: string, selection: string, fromContextParent: ArgumentDefinition>, - setContextLocations: (ObjectType | InterfaceType | UnionType)[], + setContextLocations: (ObjectType | InterfaceType)[], errorCollector: GraphQLError[], metadata: FederationMetadata, }): void { @@ -584,35 +583,82 @@ function validateFieldValue({ for (const location of setContextLocations) { // for each location, we need to validate that the selection will result in exactly one field being selected // the number of selection sets created will be the same - const explodedTypes = location.kind === 'UnionType' ? location.types() : [location]; - for (const explodedType of explodedTypes) { - let selectionSet: SelectionSet; - try { - selectionSet = parseSelectionSet({ parentType: explodedType, source: selection}); - } catch (e) { + let selectionSet: SelectionSet; + try { + selectionSet = parseSelectionSet({ parentType: location, source: selection}); + } catch (e) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid for type ${location.name}. Error: ${e.message}`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } + if (selectionSetHasDirectives(selectionSet)) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: directives are not allowed in the selection`, + { nodes: sourceASTs(fromContextParent) } + )); + } + if (selectionSetHasAlias(selectionSet)) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: aliases are not allowed in the selection`, + { nodes: sourceASTs(fromContextParent) } + )); + } + + if (selectionType === 'field') { + const { resolvedType } = validateFieldValueType({ + currentType: location, + selectionSet, + errorCollector, + metadata, + fromContextParent, + }); + if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid for type ${explodedType.name}. Error: ${e.message}`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, { nodes: sourceASTs(fromContextParent) } )); return; } - if (selectionSetHasDirectives(selectionSet)) { + } else if (selectionType === 'inlineFragment') { + // ensure that each location maps to exactly one fragment + const types = selectionSet.selections() + .filter((s): s is FragmentSelection => s.kind === 'FragmentSelection') + .filter(s => { + const { typeCondition } = s.element; + assert(typeCondition, 'Expected a type condition on FragmentSelection'); + if (typeCondition.kind === 'ObjectType') { + return location.name === typeCondition.name; + } else if (typeCondition.kind === 'InterfaceType') { + return location.kind === 'InterfaceType' ? location.name === typeCondition.name : typeCondition.isPossibleRuntimeType(location); + } else if (typeCondition.kind === 'UnionType') { + if (location.kind === 'InterfaceType') { + return false; + } else { + return typeCondition.types().includes(location); + } + } else { + assertUnreachable(typeCondition); + } + }); + + if (types.length === 0) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: directives are not allowed in the selection`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no type condition matches the location "${location.coordinate}"`, { nodes: sourceASTs(fromContextParent) } )); - } - if (selectionSetHasAlias(selectionSet)) { + return; + } else if (types.length > 1) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: aliases are not allowed in the selection`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple type conditions match the location "${location.coordinate}"`, { nodes: sourceASTs(fromContextParent) } )); - } - - if (selectionType === 'field') { + return; + } else { const { resolvedType } = validateFieldValueType({ - currentType: explodedType, - selectionSet, + currentType: location, + selectionSet: types[0].selectionSet, errorCollector, metadata, fromContextParent, @@ -624,56 +670,6 @@ function validateFieldValue({ )); return; } - } else if (selectionType === 'inlineFragment') { - // ensure that each explodedType maps to exactly one fragment - const types = selectionSet.selections() - .filter((s): s is FragmentSelection => s.kind === 'FragmentSelection') - .filter(s => { - const { typeCondition } = s.element; - assert(typeCondition, 'Expected a type condition on FragmentSelection'); - if (typeCondition.kind === 'ObjectType') { - return explodedType.name === typeCondition.name; - } else if (typeCondition.kind === 'InterfaceType') { - return explodedType.kind === 'InterfaceType' ? explodedType.name === typeCondition.name : typeCondition.isPossibleRuntimeType(explodedType); - } else if (typeCondition.kind === 'UnionType') { - if (explodedType.kind === 'InterfaceType') { - return false; - } else { - return typeCondition.types().includes(explodedType); - } - } else { - assertUnreachable(typeCondition); - } - }); - - if (types.length === 0) { - errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no type condition matches the location "${explodedType.coordinate}"`, - { nodes: sourceASTs(fromContextParent) } - )); - return; - } else if (types.length > 1) { - errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple type conditions match the location "${explodedType.coordinate}"`, - { nodes: sourceASTs(fromContextParent) } - )); - return; - } else { - const { resolvedType } = validateFieldValueType({ - currentType: explodedType, - selectionSet: types[0].selectionSet, - errorCollector, - metadata, - fromContextParent, - }); - if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { - errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, - { nodes: sourceASTs(fromContextParent) } - )); - return; - } - } } } } @@ -1529,7 +1525,7 @@ export class FederationBlueprint extends SchemaBlueprint { // validate @context and @fromContext const contextDirective = metadata.contextDirective(); - const contextToTypeMap = new Map(); + const contextToTypeMap = new Map(); for (const application of contextDirective.applications()) { const parent = application.parent; const name = application.arguments().name as string; diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index 8b1b298f5..a51937f06 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -33,7 +33,7 @@ export class ContextSpecDefinition extends FeatureDefinition { this.contextDirectiveSpec = createDirectiveSpecification({ name: ContextDirectiveName.CONTEXT, - locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT, DirectiveLocation.UNION], + locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT], args: [{ name: 'name', type: (schema) =>new NonNullType(schema.stringType()) }], composes: true, repeatable: true, diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 1fc2bccfe..c9237a55a 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8920,6 +8920,7 @@ describe('@fromContext impacts on query planning', () => { }, } `); + expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([{ kind: 'KeyRenamer', path: ['..', 'prop'], renameKeyTo: 'Subgraph1_U_field_a'}]); }); it('fromContext variable is from different subgraph', () => { @@ -9057,6 +9058,7 @@ describe('@fromContext impacts on query planning', () => { }, } `); + expect((plan as any).node.nodes[3].node.contextRewrites).toEqual([{ kind: 'KeyRenamer', path: ['..', 'prop'], renameKeyTo: 'Subgraph1_U_field_a'}]); }); it.skip('fromContext variable is a list', () => { @@ -9231,5 +9233,6 @@ describe('@fromContext impacts on query planning', () => { }, } `); + expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([{ kind: 'KeyRenamer', path: ['..', 'prop'], renameKeyTo: 'Subgraph1_U_field_a'}]); }); }); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index d79d0d9b6..3a3cfed44 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -61,7 +61,6 @@ import { typesCanBeMerged, Supergraph, sameType, - assertUnreachable, isInputType, possibleRuntimeTypes, NamedType, @@ -1674,33 +1673,52 @@ type FieldToAlias = { alias: string, } -function createPathFromSelection(selection: Selection): string[] { - const path: string[] = []; +// function createPathFromSelection(selection: Selection): string[] { +// const path: string[] = []; - const helper = (sel: Selection) => { - if (sel.kind === 'FieldSelection') { - path.push(sel.element.name); - } else if (sel.kind === 'FragmentSelection') { - path.push(`... ${sel.element.typeCondition ? sel.element.typeCondition.name : ''}`); - } else { - assertUnreachable(sel); - } - const ss = sel.selectionSet; - if (ss && ss.selections().length > 0) { - helper(ss.selections()[0]); - } - }; +// const helper = (sel: Selection) => { +// if (sel.kind === 'FieldSelection') { +// path.push(sel.element.name); +// } else if (sel.kind === 'FragmentSelection') { +// path.push(`... ${sel.element.typeCondition ? sel.element.typeCondition.name : ''}`); +// } else { +// assertUnreachable(sel); +// } +// const ss = sel.selectionSet; +// if (ss && ss.selections().length > 0) { +// helper(ss.selections()[0]); +// } +// }; - helper(selection); - return path; -} - -function selectionAsKeyRenamer(selection: Selection, relPath: string[], alias: string): FetchDataKeyRenamer { - return { - kind: 'KeyRenamer', - path: relPath.concat(createPathFromSelection(selection)), - renameKeyTo: alias, - } +// helper(selection); +// return path; +// } + +// function selectionAsKeyRenamer(selection: Selection, relPath: string[], alias: string): FetchDataKeyRenamer { +// return { +// kind: 'KeyRenamer', +// path: relPath.concat(createPathFromSelection(selection)), +// renameKeyTo: alias, +// } +// } + +function selectionSetAsKeyRenamers(selectionSet: SelectionSet, relPath: string[], alias: string): FetchDataKeyRenamer[] { + return selectionSet.selections().map((selection: Selection): FetchDataKeyRenamer[] | undefined => { + if (selection.kind === 'FieldSelection') { + return [{ + kind: 'KeyRenamer', + path: [...relPath, selection.element.name], + renameKeyTo: alias, + }]; + } else if (selection.kind === 'FragmentSelection') { + const element = selection.element; + if (element.typeCondition) { + return selectionSetAsKeyRenamers(selection.selectionSet, [...relPath, `... on ${element.typeCondition.name}`], alias); + } + } + return undefined; + }).filter(isDefined) + .reduce((acc, val) => acc.concat(val), []); } function computeAliasesForNonMergingFields(selections: SelectionSetAtPath[], aliasCollector: FieldToAlias[]) { @@ -4403,8 +4421,10 @@ function computeGroupsForTree( newGroup.addParent({ group, path: path.inGroup() }); for (const [_, { contextId, selectionSet, relativePath, subgraphArgType }] of parameterToContext) { newGroup.addInputContext(contextId, subgraphArgType); - const keyRenamer = selectionAsKeyRenamer(selectionSet.selections()[0], relativePath, contextId); - newGroup.addContextRenamer(keyRenamer); + const keyRenamers = selectionSetAsKeyRenamers(selectionSet, relativePath, contextId); + for (const keyRenamer of keyRenamers) { + newGroup.addContextRenamer(keyRenamer); + } } tree.parameterToContext = null; // We also ensure to get the __typename of the current type in the "original" group. From 9477facbf1473241d32bf5d37c2ab818c32d395e Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 7 May 2024 09:33:31 -0500 Subject: [PATCH 31/82] fix prettier and spelling --- .cspell/cspell-dict.txt | 1 + .../src/__tests__/buildPlan.test.ts | 24 ++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.cspell/cspell-dict.txt b/.cspell/cspell-dict.txt index b7e7c5fab..50263058a 100644 --- a/.cspell/cspell-dict.txt +++ b/.cspell/cspell-dict.txt @@ -182,6 +182,7 @@ referencer relatity Remaings Remainings +Renamers reoptimized repeateable reponse diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index c9237a55a..053a36467 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8920,7 +8920,13 @@ describe('@fromContext impacts on query planning', () => { }, } `); - expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([{ kind: 'KeyRenamer', path: ['..', 'prop'], renameKeyTo: 'Subgraph1_U_field_a'}]); + expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ + { + kind: 'KeyRenamer', + path: ['..', 'prop'], + renameKeyTo: 'Subgraph1_U_field_a', + }, + ]); }); it('fromContext variable is from different subgraph', () => { @@ -9058,7 +9064,13 @@ describe('@fromContext impacts on query planning', () => { }, } `); - expect((plan as any).node.nodes[3].node.contextRewrites).toEqual([{ kind: 'KeyRenamer', path: ['..', 'prop'], renameKeyTo: 'Subgraph1_U_field_a'}]); + expect((plan as any).node.nodes[3].node.contextRewrites).toEqual([ + { + kind: 'KeyRenamer', + path: ['..', 'prop'], + renameKeyTo: 'Subgraph1_U_field_a', + }, + ]); }); it.skip('fromContext variable is a list', () => { @@ -9233,6 +9245,12 @@ describe('@fromContext impacts on query planning', () => { }, } `); - expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([{ kind: 'KeyRenamer', path: ['..', 'prop'], renameKeyTo: 'Subgraph1_U_field_a'}]); + expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ + { + kind: 'KeyRenamer', + path: ['..', 'prop'], + renameKeyTo: 'Subgraph1_U_field_a', + }, + ]); }); }); From 00fedbff2e99720716a41919d4f4955ac9a22ebe Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 7 May 2024 11:54:50 -0500 Subject: [PATCH 32/82] Add ContextFieldValue scalar to subgraph schema. --- internals-js/src/specs/contextSpec.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index a51937f06..729414342 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -7,14 +7,18 @@ import { FeatureVersion, } from "./coreSpec"; import { NonNullType } from "../definitions"; -import { DirectiveSpecification, createDirectiveSpecification } from "../directiveAndTypeSpecification"; +import { DirectiveSpecification, createDirectiveSpecification, createScalarTypeSpecification } from "../directiveAndTypeSpecification"; import { registerKnownFeature } from "../knownCoreFeatures"; import { Subgraph } from '../federation'; +import { assert } from '../utils'; export enum ContextDirectiveName { CONTEXT = 'context', FROM_CONTEXT = 'fromContext', } + +const fieldValueScalar = 'ContextFieldValue'; + export class ContextSpecDefinition extends FeatureDefinition { public static readonly directiveName = 'context'; public static readonly identity = @@ -31,10 +35,18 @@ export class ContextSpecDefinition extends FeatureDefinition { ) ); + this.registerType(createScalarTypeSpecification({ name: fieldValueScalar })); + this.contextDirectiveSpec = createDirectiveSpecification({ name: ContextDirectiveName.CONTEXT, locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT], - args: [{ name: 'name', type: (schema) =>new NonNullType(schema.stringType()) }], + args: [{ name: 'name', type: (schema, feature) => { + assert(feature, "Shouldn't be added without being attached to a @link spec"); + const fieldValue = feature.typeNameInSchema(fieldValueScalar); + const fieldValueType = schema.type(fieldValue); + assert(fieldValueType, () => `Expected "${fieldValue}" to be defined`); + return new NonNullType(fieldValueType); + }}], composes: true, repeatable: true, supergraphSpecification: (fedVersion) => CONTEXT_VERSIONS.getMinimumRequiredVersion(fedVersion), From 647a5a4481c8b4b730366627d4aacb3b80fdd50e Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 7 May 2024 11:58:08 -0500 Subject: [PATCH 33/82] delete dead code --- composition-js/src/merging/merge.ts | 40 ----------------------------- 1 file changed, 40 deletions(-) diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index e26882d53..23262fb40 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -75,7 +75,6 @@ import { sourceIdentity, FeatureUrl, isFederationDirectiveDefinedInSchema, - parseSelectionSet, parseContext, CoreFeature, Subgraph, @@ -310,7 +309,6 @@ class Merger { private latestFedVersionUsed: FeatureVersion; private joinDirectiveIdentityURLs = new Set(); private schemaToImportNameToFeatureUrl = new Map>(); - private contextToTypeMap = new Map, usages: { usage: string, argumentDefinition: ArgumentDefinition> }[] }>(); constructor(readonly subgraphs: Subgraphs, readonly options: CompositionOptions) { this.latestFedVersionUsed = this.getLatestFederationVersionUsed(); @@ -3045,44 +3043,6 @@ class Merger { } } } - this.validateContextUsages(); - } - - // private traverseSelectionSetForType( - // selection: string, - // type: ObjectType, - // ) { - // const selectionSet = new SelectionSet(type, ) - // } - - private validateContextUsages() { - // For each usage of a context, we need to validate that all set contexts could fulfill the selection of the context - this.contextToTypeMap.forEach(({ usages, types }, context) => { - for (const { usage, argumentDefinition } of usages) { - if (types.size === 0) { - this.errors.push(ERRORS.CONTEXT_NOT_SET.err( - `Context "${context}" is used in "${argumentDefinition.coordinate}" but is never set in any subgraph.`, - { nodes: sourceASTs(argumentDefinition) } - )); - } - // const resolvedTypes = []; - for (const type of types) { - // now ensure that for each type, the selection is satisfiable and collect the resolved type - try { - parseSelectionSet({ parentType: type, source: usage }); - } catch (error) { - if (error instanceof GraphQLError) { - this.errors.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${argumentDefinition.coordinate}" but the selection is invalid: ${error.message}`, - { nodes: sourceASTs(argumentDefinition) } - )); - } else { - throw error; - } - } - } - } - }); } private updateInaccessibleErrorsWithLinkToSubgraphs( From f355e8b351069ed701c797c1d3fa1806df4f43f3 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 7 May 2024 15:15:27 -0500 Subject: [PATCH 34/82] change logic of valid non-contextual arguments --- .../src/__tests__/compose.setContext.test.ts | 218 ++++++++++++++++++ composition-js/src/merging/merge.ts | 42 +++- internals-js/src/error.ts | 7 + internals-js/src/federation.ts | 7 + 4 files changed, 263 insertions(+), 11 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index a175f904c..363dbd4e3 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -918,6 +918,50 @@ describe('setContext tests', () => { expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: aliases are not allowed in the selection'); }); + it('context name is invalid', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "_context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$_context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context name \"_context\" cannot start with an underscore.'); + }); + it('context selection contains a query directive', () => { const subgraph1 = { name: 'Subgraph1', @@ -1007,4 +1051,178 @@ describe('setContext tests', () => { expect(result.errors?.length).toBe(1); expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"is used in \"U.field(a:)\" but the selection is invalid: One of the types in the selection is an interfaceObject: \"T\"'); }); + + it('contextual argument is present in multiple subgraphs -- success case', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! @shareable + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + field: Int! @shareable + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it('contextual argument is present in multiple subgraphs, not nullable, no default', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! @shareable + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + field(a: String!): Int! @shareable + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('Argument \"U.field(a:)\" is contextual in at least one subgraph but in \"U.field(a:)\" it does not have @fromContext, is not nullable and has no default value.'); + }); + + it('contextual argument is present in multiple subgraphs, nullable', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! @shareable + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + field(a: String): Int! @shareable + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it('contextual argument is present in multiple subgraphs, default value', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! @shareable + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + field(a: String! = "default"): Int! @shareable + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); }); diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index 23262fb40..a4664f032 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -1659,6 +1659,17 @@ class Merger { if (mergeContext.some(({ usedOverridden, overrideLabel }) => usedOverridden || !!overrideLabel)) { return true; } + + // if there is a @fromContext directive on one of the sources, we need a join__field + if (sources.some((s, idx) => { + const fromContextDirective = this.subgraphs.values()[idx].metadata().fromContextDirective(); + if (isFederationDirectiveDefinedInSchema(fromContextDirective)) { + return (s?.appliedDirectivesOf(fromContextDirective).length ?? 0) > 0; + } + return false; + })) { + return true; + } // We can avoid the join__field if: // 1) the field exists in all sources having the field parent type, @@ -1884,24 +1895,33 @@ class Merger { // in those cases. const arg = dest.addArgument(argName); + // helper function to determine if an argument is contextual in a given subgraph const isContextualArg = (index: number, arg: ArgumentDefinition> | ArgumentDefinition>) => { const fromContextDirective = this.metadata(index).fromContextDirective(); return fromContextDirective && isFederationDirectiveDefinedInSchema(fromContextDirective) && arg.appliedDirectivesOf(fromContextDirective).length >= 1; } - const hasContextual = sources.map((s, idx) => { + + const isContextualArray = sources.map((s, idx) => { const arg = s?.argument(argName); return arg && isContextualArg(idx, arg); }); - - if (hasContextual.some((c) => c === true)) { - // If any of the sources has a contextual argument, then we need to remove it from the supergraph - // and ensure that all the sources have it. - if (hasContextual.some((c) => c === false)) { - this.errors.push(ERRORS.CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS.err( - `Argument "${arg.coordinate}" is contextual in some subgraphs but not in all subgraphs: it is contextual in ${printSubgraphNames(hasContextual.map((c, i) => c ? this.names[i] : undefined).filter(isDefined))} but not in ${printSubgraphNames(hasContextual.map((c, i) => c ? undefined : this.names[i]).filter(isDefined))}`, - { nodes: sourceASTs(...sources.map((s) => s?.argument(argName))) }, - )); - } + + + + if (isContextualArray.some((c) => c === true)) { + // if the argument is contextual in some subgraph, then it should also be contextual in other subgraphs, + // unless it is nullable. Also, we need to remove it from the supergraph + isContextualArray.forEach((isContextual, idx) => { + const argument = sources[idx]?.argument(argName); + const argType = argument?.type; + if (!isContextual && argument && argType && isNonNullType(argType) && argument.defaultValue === undefined) { + this.errors.push(ERRORS.CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS.err( + `Argument "${arg.coordinate}" is contextual in at least one subgraph but in "${argument.coordinate}" it does not have @fromContext, is not nullable and has no default value.`, + { nodes: sourceASTs(sources[idx]?.argument(argName)) }, + )); + + } + }); arg.remove(); continue; } diff --git a/internals-js/src/error.ts b/internals-js/src/error.ts index 366004bd1..f3fa90e89 100644 --- a/internals-js/src/error.ts +++ b/internals-js/src/error.ts @@ -349,6 +349,12 @@ const CONTEXT_NO_RESOLVABLE_KEY = makeCodeDefinition( { addedIn: '2.8.0' }, ); +const CONTEXT_NAME_INVALID = makeCodeDefinition( + 'CONTEXT_NAME_INVALID', + 'Context name is invalid.', + { addedIn: '2.8.0' }, +); + const EXTERNAL_TYPE_MISMATCH = makeCodeDefinition( 'EXTERNAL_TYPE_MISMATCH', 'An `@external` field has a type that is incompatible with the declaration(s) of that field in other subgraphs.', @@ -750,6 +756,7 @@ export const ERRORS = { CONTEXT_INVALID_SELECTION, NO_CONTEXT_IN_SELECTION, CONTEXT_NO_RESOLVABLE_KEY, + CONTEXT_NAME_INVALID, EXTERNAL_TYPE_MISMATCH, EXTERNAL_ARGUMENT_MISSING, EXTERNAL_ARGUMENT_TYPE_MISMATCH, diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 693635980..66d2dc1ff 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1529,6 +1529,13 @@ export class FederationBlueprint extends SchemaBlueprint { for (const application of contextDirective.applications()) { const parent = application.parent; const name = application.arguments().name as string; + + if (name.startsWith('_')) { + errorCollector.push(ERRORS.CONTEXT_NAME_INVALID.err( + `Context name "${name}" cannot start with an underscore.`, + { nodes: sourceASTs(application) } + )); + } const types = contextToTypeMap.get(name); if (types) { types.push(parent); From 124760a9de4f81346862ed046d4913548b7610cf Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Tue, 7 May 2024 18:11:29 -0500 Subject: [PATCH 35/82] add more validation tests --- .../src/__tests__/compose.setContext.test.ts | 190 ++++++++++++++++++ internals-js/src/federation.ts | 27 +++ 2 files changed, 217 insertions(+) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 363dbd4e3..54bb85daf 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -1225,4 +1225,194 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); + + it('contextual argument on a directive definition argument', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + directive @foo( + a: String! @fromContext(field: "$context { prop }") + ) on FIELD_DEFINITION + + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field: Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext argument cannot be used on a directive definition \"@foo(a:)\".'); + }); + + it('forbid default values on contextual arguments', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + field( + a: String! = "default" @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext arguments may not have a default value: \"U.field(a:)\".'); + }); + + it('forbid contextual arguments on interfaces', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + interface I @key(fields: "id") { + id: ID! + field( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U implements I @key(fields: "id") { + id: ID! + field( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext argument cannot be used on a field that exists on an interface \"I.field(a:)\".'); + }); + + it('forbid contextual arguments on interfaces', () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + interface I @key(fields: "id") { + id: ID! + field: Int! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + } + + type U implements I @key(fields: "id") { + id: ID! + field( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.schema).toBeUndefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toBe('[Subgraph1] Field U.field includes required argument a that is missing from the Interface field I.field.'); + }); }); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 66d2dc1ff..7735e1beb 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1548,7 +1548,34 @@ export class FederationBlueprint extends SchemaBlueprint { for (const application of fromContextDirective.applications()) { const { field } = application.arguments(); const { context, selection } = parseContext(field); + + // error if parent's parent is a directive definition + if (application.parent.parent.kind === 'DirectiveDefinition') { + errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( + `@fromContext argument cannot be used on a directive definition "${application.parent.coordinate}".`, + { nodes: sourceASTs(application) } + )); + continue; + } + const parent = application.parent as ArgumentDefinition>; + + // error if parent's parent is an interface + if (parent?.parent?.parent?.kind === 'InterfaceType') { + errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( + `@fromContext argument cannot be used on a field that exists on an interface "${application.parent.coordinate}".`, + { nodes: sourceASTs(application) } + )); + continue; + } + + if (parent.defaultValue !== undefined) { + errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( + `@fromContext arguments may not have a default value: "${parent.coordinate}".`, + { nodes: sourceASTs(application) } + )); + } + if (!context || !selection) { errorCollector.push(ERRORS.NO_CONTEXT_IN_SELECTION.err( `@fromContext argument does not reference a context "${field}".`, From 96b428ef68d58b277fe3de3b7bec9b2009d432b8 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Wed, 8 May 2024 10:28:56 -0500 Subject: [PATCH 36/82] extractSubgraphFromSupergraph code review comments --- .../src/__tests__/compose.setContext.test.ts | 2 +- .../src/extractSubgraphsFromSupergraph.ts | 71 +++++++++---------- internals-js/src/federation.ts | 4 +- internals-js/src/specs/contextSpec.ts | 6 +- 4 files changed, 41 insertions(+), 42 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 54bb85daf..b20b02d9c 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -959,7 +959,7 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context name \"_context\" cannot start with an underscore.'); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context name \"_context\" may not contain an underscore.'); }); it('context selection contains a query directive', () => { diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 6ab606cc1..ec7ae0b58 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -40,8 +40,7 @@ import { parseSelectionSet } from "./operations"; import fs from 'fs'; import path from 'path'; import { validateStringContainsBoolean } from "./utils"; -import { errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; -import { Kind, TypeNode, parseType } from 'graphql'; +import { CONTEXT_VERSIONS, ContextSpecDefinition, DirectiveDefinition, errorCauses, isFederationDirectiveDefinedInSchema, printErrors } from "."; function filteredTypes( supergraph: Schema, @@ -317,16 +316,16 @@ function addAllEmptySubgraphTypes({ // (on top of it making sense code-wise since both type behave exactly the same for most of what we're doing here). case 'InterfaceType': case 'ObjectType': - objOrItfTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph) }); + objOrItfTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); break; case 'InputObjectType': - inputObjTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph) }); + inputObjTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); break; case 'EnumType': - enumTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph) }); + enumTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); break; case 'UnionType': - unionTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph) }); + unionTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); break; case 'ScalarType': // Scalar are a bit special in that they don't have any sub-component, so we don't track them beyond adding them to the @@ -352,6 +351,7 @@ function addEmptyType( type: T, typeApplications: Directive[], getSubgraph: (application: Directive) => Subgraph | undefined, + supergraph: Schema, ): SubgraphTypeInfo { // In fed2, we always mark all types with `@join__type` but making sure. assert(typeApplications.length > 0, `Missing @join__type on ${type}`) @@ -384,18 +384,30 @@ function addEmptyType( } } - const contextApplications = type.appliedDirectivesOf('context'); // TODO: is there a better way to get this? - // for every application, apply the context directive to the correct subgraph - for (const application of contextApplications) { - const { name } = application.arguments(); - const match = name.match(/(.*?)__([\s\S]*)/); - const graph = match ? match[1] : undefined; - const context = match ? match[2] : undefined; - - const subgraphInfo = subgraphsInfo.get(graph ? graph.toUpperCase() : undefined); - const contextDirective = subgraphInfo?.subgraph.metadata().contextDirective(); - if (subgraphInfo && contextDirective && isFederationDirectiveDefinedInSchema(contextDirective)) { - subgraphInfo.type.applyDirective(contextDirective, {name: context}); + const coreFeatures = supergraph.coreFeatures; + assert(coreFeatures, 'Should have core features'); + const contextFeature = coreFeatures.getByIdentity(ContextSpecDefinition.identity); + let supergraphContextDirective: DirectiveDefinition<{ name: string}> | undefined; + if (contextFeature) { + const contextSpec = CONTEXT_VERSIONS.find(contextFeature.url.version); + assert(contextSpec, 'Should have context spec'); + supergraphContextDirective = contextSpec.contextDirective(supergraph); + } + + if (supergraphContextDirective) { + const contextApplications = type.appliedDirectivesOf(supergraphContextDirective); + // for every application, apply the context directive to the correct subgraph + for (const application of contextApplications) { + const { name } = application.arguments(); + const match = name.match(/^(.*)__([A-Za-z]\w*)$/); + const graph = match ? match[1] : undefined; + const context = match ? match[2] : undefined; + assert(graph, `Invalid context name ${name} found in ${application} on ${application.parent}: does not match the expected pattern`); + const subgraphInfo = subgraphsInfo.get(graph.toUpperCase()); + const contextDirective = subgraphInfo?.subgraph.metadata().contextDirective(); + if (subgraphInfo && contextDirective && isFederationDirectiveDefinedInSchema(contextDirective)) { + subgraphInfo.type.applyDirective(contextDirective, {name: context}); + } } } return subgraphsInfo; @@ -599,22 +611,6 @@ function errorToString(e: any,): string { return causes ? printErrors(causes) : String(e); } -const typeFromTypeNode = (typeNode: TypeNode, schema: Schema): Type => { - if (typeNode.kind === Kind.NON_NULL_TYPE) { - const type = typeFromTypeNode(typeNode.type, schema); - assert(type.kind !== 'NonNullType', 'A non-null type cannot be nested in another non-null type'); - return new NonNullType(type); - } else if (typeNode.kind === Kind.LIST_TYPE) { - return new ListType(typeFromTypeNode(typeNode.type, schema)); - } - - const type = schema.type(typeNode.name.value); - if (!type) { - throw new Error(`Type ${typeNode.name.value} not found in schema`); - } - return type; -}; - function addSubgraphField({ field, type, @@ -645,17 +641,16 @@ function addSubgraphField({ if (joinFieldArgs?.contextArguments) { const fromContextDirective = subgraph.metadata().fromContextDirective(); if (!isFederationDirectiveDefinedInSchema(fromContextDirective)) { - throw new Error(`@context directive is not defined in the subgraph schema: ${subgraph.name}`); + throw new Error(`@fromContext directive is not defined in the subgraph schema: ${subgraph.name}`); } else { for (const arg of joinFieldArgs.contextArguments) { // this code will remove the subgraph name from the context - const match = arg.context.match(/.*?__([\s\S]*)/); + const match = arg.context.match(/^.*__([A-Za-z]\w*)$/); if (!match) { throw new Error(`Invalid context argument: ${arg.context}`); } - const typeNode = parseType(arg.type); - subgraphField.addArgument(arg.name, typeFromTypeNode(typeNode, subgraph.schema)); + subgraphField.addArgument(arg.name, decodeType(arg.type, subgraph.schema, subgraph.name)); const argOnField = subgraphField.argument(arg.name); argOnField?.applyDirective(fromContextDirective, { field: `\$${match[1]} ${arg.selection}`, diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 7735e1beb..9ee7eefb4 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1530,9 +1530,9 @@ export class FederationBlueprint extends SchemaBlueprint { const parent = application.parent; const name = application.arguments().name as string; - if (name.startsWith('_')) { + if (name.includes('_')) { errorCollector.push(ERRORS.CONTEXT_NAME_INVALID.err( - `Context name "${name}" cannot start with an underscore.`, + `Context name "${name}" may not contain an underscore.`, { nodes: sourceASTs(application) } )); } diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index 729414342..e4d117633 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -6,7 +6,7 @@ import { FeatureUrl, FeatureVersion, } from "./coreSpec"; -import { NonNullType } from "../definitions"; +import { DirectiveDefinition, NonNullType, Schema } from "../definitions"; import { DirectiveSpecification, createDirectiveSpecification, createScalarTypeSpecification } from "../directiveAndTypeSpecification"; import { registerKnownFeature } from "../knownCoreFeatures"; import { Subgraph } from '../federation'; @@ -72,6 +72,10 @@ export class ContextSpecDefinition extends FeatureDefinition { get defaultCorePurpose(): CorePurpose { return 'SECURITY'; } + + contextDirective(schema: Schema): DirectiveDefinition<{ name: string }> | undefined { + return this.directive(schema, ContextSpecDefinition.directiveName); + } } export const CONTEXT_VERSIONS = From 34b2fff441c6e4f19460d5bbb057b9ca07ae4faa Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Thu, 9 May 2024 10:27:54 -0500 Subject: [PATCH 37/82] code review updates --- internals-js/src/federation.ts | 4 +--- query-graphs-js/src/querygraph.ts | 19 +++++++------------ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 9ee7eefb4..9349a5186 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -375,9 +375,7 @@ const wrapResolvedType = ({ let type: NamedType | WrapperType = resolvedType; while(stack.length > 0) { const kind = stack.pop(); - if (kind === 'NonNullType' && type.kind !== 'NonNullType') { - type = new NonNullType(type); - } else if (kind === 'ListType') { + if (kind === 'ListType') { type = new ListType(type); } } diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index 08e75f4e9..08d2acbc1 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -192,8 +192,8 @@ export class Edge { public overrideCondition?: OverrideCondition, /** - * Potentially multiple context conditions. When @fromContext is used on a argument definition, the edge connecting the type to the - * argument needs to reflect that the condition must be satisfied in order for the edge to be taken + * Potentially multiple context conditions. When @fromContext is used on a argument definition, the edge representing + * the argument's field needs to reflect that the condition must be satisfied in order for the edge to be taken */ requiredContexts?: ContextCondition[], ) { @@ -876,8 +876,8 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr } /** - * Now we'll handle instances of @fromContext. For each instance where @fromContext exists, I want to add edges back to each place - * place where the context is set, along with conditions on the edge that goes to the field + * Now we'll handle instances of @fromContext. For each argument with @fromContext, I want to add its corresponding + * context conditions to the edge corresponding to the argument's field */ for (const [i, subgraph] of subgraphs.entries()) { const subgraphSchema = schemas[i]; @@ -918,14 +918,13 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr subgraph, _v => { return undefined; }, e => { - if (e.head.type.kind === 'ObjectType' && e.tail.type.kind === 'ScalarType') { - const coordinate = `${e.head.type.name}.${e.transition.toString()}`; + if (e.head.type.kind === 'ObjectType' && e.transition.kind === 'FieldCollection') { + const coordinate = `${e.head.type.name}.${e.transition.definition.name}`; const requiredContexts = coordinateMap.get(coordinate); if (requiredContexts) { - const headInSupergraph = builder.vertexForTypeAndSubgraph(e.head.type.name, subgraph.name); + const headInSupergraph = copyPointers[i].copiedVertex(e.head); assert(headInSupergraph, () => `Vertex for type ${e.head.type.name} not found in supergraph`); const edgeInSupergraph = builder.edge(headInSupergraph, e.index); - e.addToContextConditions(requiredContexts); edgeInSupergraph.addToContextConditions(requiredContexts); } } @@ -1111,10 +1110,6 @@ class GraphBuilder { return indexes == undefined ? [] : indexes.map(i => this.vertices[i]); } - vertexForTypeAndSubgraph(typeName: string, source: string): Vertex | undefined { - return this.verticesForType(typeName).find(v => v.source === source); - } - root(kind: SchemaRootKind): Vertex | undefined { return this.rootVertices.get(kind); } From dcef4125060831842e46fc7bac1762ab45df4ba3 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Thu, 9 May 2024 18:49:08 -0500 Subject: [PATCH 38/82] fixing up union types --- .../src/__tests__/compose.setContext.test.ts | 56 ++++++++++++++++++- internals-js/src/federation.ts | 28 ++++++---- internals-js/src/specs/contextSpec.ts | 2 +- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index b20b02d9c..32a9a6780 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -600,7 +600,59 @@ describe('setContext tests', () => { assertCompositionSuccess(result); }); - it("@context invalid on union", () => { + it("@context works on union when all types have the designated property", () => { + const subgraph1 = { + name: 'Subgraph1', + utl: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + union T @context(name: "context") = T1 | T2 + + type T1 @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + a: String! + } + + type T2 @key(fields: "id") @context(name: "context") { + id: ID! + u: U! + prop: String! + b: String! + } + + type U @key(fields: "id") { + id: ID! + field ( + a: String! @fromContext(field: "$context { prop }") + ): Int! + } + ` + }; + + const subgraph2 = { + name: 'Subgraph2', + utl: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type U @key(fields: "id") { + id: ID! + } + ` + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + assertCompositionSuccess(result); + }); + + it("@context fails on union when type is missing prop", () => { const subgraph1 = { name: 'Subgraph1', utl: 'https://Subgraph1', @@ -650,7 +702,7 @@ describe('setContext tests', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Directive "@context" may not be used on UNION.'); + expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T2. Error: Cannot query field "prop" on type "T2".'); }); it.todo('type mismatch in context variable'); it('nullability mismatch is ok if contextual value is non-nullable', () => { diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 9349a5186..1845250fd 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -393,7 +393,7 @@ const validateFieldValueType = ({ selectionSet: SelectionSet, errorCollector: GraphQLError[], metadata: FederationMetadata, - fromContextParent: ArgumentDefinition>, + fromContextParent: ArgumentDefinition>, }): { resolvedType: InputType | undefined } => { const selections = selectionSet.selections(); @@ -445,7 +445,7 @@ const validateSelectionFormat = ({ } : { context: string, selection: string, - fromContextParent: ArgumentDefinition>, + fromContextParent: ArgumentDefinition>, errorCollector: GraphQLError[], }): { selectionType: 'error' | 'field' | 'inlineFragment', @@ -562,8 +562,8 @@ function validateFieldValue({ } : { context: string, selection: string, - fromContextParent: ArgumentDefinition>, - setContextLocations: (ObjectType | InterfaceType)[], + fromContextParent: ArgumentDefinition>, + setContextLocations: (ObjectType | InterfaceType | UnionType)[], errorCollector: GraphQLError[], metadata: FederationMetadata, }): void { @@ -578,7 +578,15 @@ function validateFieldValue({ return; } - for (const location of setContextLocations) { + // reduce setContextLocations to an array of ObjectType and InterfaceType. If a UnionType is present, use .types() to expand it + const expandedTypes = setContextLocations.reduce((acc, location) => { + if (location.kind === 'UnionType') { + return acc.concat(location.types()); + } + return acc.concat(location); + }, [] as (ObjectType | InterfaceType)[]); + + for (const location of expandedTypes) { // for each location, we need to validate that the selection will result in exactly one field being selected // the number of selection sets created will be the same let selectionSet: SelectionSet; @@ -631,11 +639,7 @@ function validateFieldValue({ } else if (typeCondition.kind === 'InterfaceType') { return location.kind === 'InterfaceType' ? location.name === typeCondition.name : typeCondition.isPossibleRuntimeType(location); } else if (typeCondition.kind === 'UnionType') { - if (location.kind === 'InterfaceType') { - return false; - } else { - return typeCondition.types().includes(location); - } + return location.name === typeCondition.name; } else { assertUnreachable(typeCondition); } @@ -1523,7 +1527,7 @@ export class FederationBlueprint extends SchemaBlueprint { // validate @context and @fromContext const contextDirective = metadata.contextDirective(); - const contextToTypeMap = new Map(); + const contextToTypeMap = new Map(); for (const application of contextDirective.applications()) { const parent = application.parent; const name = application.arguments().name as string; @@ -1556,7 +1560,7 @@ export class FederationBlueprint extends SchemaBlueprint { continue; } - const parent = application.parent as ArgumentDefinition>; + const parent = application.parent as ArgumentDefinition>; // error if parent's parent is an interface if (parent?.parent?.parent?.kind === 'InterfaceType') { diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index e4d117633..a0ced7bbd 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -39,7 +39,7 @@ export class ContextSpecDefinition extends FeatureDefinition { this.contextDirectiveSpec = createDirectiveSpecification({ name: ContextDirectiveName.CONTEXT, - locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT], + locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT, DirectiveLocation.UNION], args: [{ name: 'name', type: (schema, feature) => { assert(feature, "Shouldn't be added without being attached to a @link spec"); const fieldValue = feature.typeNameInSchema(fieldValueScalar); From 9298a563cbc82c3d661cbf4e4bb19af721abd0b4 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Thu, 9 May 2024 21:46:28 -0500 Subject: [PATCH 39/82] Add a union test into buildPlan. Also fixed up the logic that tests for whether a context rewrite exists before we add it. --- query-graphs-js/src/graphPath.ts | 3 +- .../src/__tests__/buildPlan.test.ts | 275 ++++++++++++++++++ query-planner-js/src/buildPlan.ts | 16 +- 3 files changed, 291 insertions(+), 3 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 201bbb157..97fbd98ea 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -595,6 +595,7 @@ export class GraphPath(); } contextToSelection[idx]?.set(entry.id, entry.selectionSet); + parameterToContext[parameterToContext.length-1]?.set(entry.paramName, { contextId: entry.id, relativePath: Array(entry.level).fill(".."), selectionSet: entry.selectionSet, subgraphArgType: entry.argType } ); } return { @@ -1899,7 +1900,7 @@ function canSatisfyConditions 1) { diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 053a36467..0c1076421 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -9253,4 +9253,279 @@ describe('@fromContext impacts on query planning', () => { }, ]); }); + + it('fromContext with type conditions', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: I! + } + + interface I @context(name: "context") @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + + type A implements I @key(fields: "id"){ + id: ID! + u: U! + prop: String! + } + + type B implements I @key(fields: "id"){ + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + b: String! + field(a: String! @fromContext(field: "$context ... on I { prop }")): Int! + } + `, + }; + + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + `, + }; + + const asFed2Service = (service: ServiceDefinition) => { + return { + ...service, + typeDefs: asFed2SubgraphDocument(service.typeDefs, { + includeAllImports: true, + }), + }; + }; + + const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { + return composeServices(services.map((s) => asFed2Service(s))); + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.errors).toBeUndefined(); + const [api, queryPlanner] = [ + result.schema!.toAPISchema(), + new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + ]; + // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); + const operation = operationFromDocument( + api, + gql` + { + t { + u { + b + field + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + prop + u { + __typename + id + b + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + id + b + field(a: $Subgraph1_U_field_a) + } + } + }, + }, + }, + } + `); + expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ + { + kind: 'KeyRenamer', + path: ['..', '... on I', 'prop'], + renameKeyTo: 'Subgraph1_U_field_a', + }, + ]); + }); + + it('fromContext with type conditions for union', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + + union T @context(name: "context") = A | B + + type A @key(fields: "id"){ + id: ID! + u: U! + prop: String! + } + + type B @key(fields: "id"){ + id: ID! + u: U! + prop: String! + } + + type U @key(fields: "id") { + id: ID! + b: String! + field(a: String! @fromContext(field: "$context ... on A { prop } ... on B { prop }")): Int! + } + `, + }; + + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + type U @key(fields: "id") { + id: ID! + } + `, + }; + + const asFed2Service = (service: ServiceDefinition) => { + return { + ...service, + typeDefs: asFed2SubgraphDocument(service.typeDefs, { + includeAllImports: true, + }), + }; + }; + + const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { + return composeServices(services.map((s) => asFed2Service(s))); + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.errors).toBeUndefined(); + const [api, queryPlanner] = [ + result.schema!.toAPISchema(), + new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + ]; + // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); + const operation = operationFromDocument( + api, + gql` + { + t { + ... on A { + u { + b + field + } + } + ... on B { + u { + b + field + } + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + __typename + ... on A { + prop + u { + __typename + id + b + } + } + ... on B { + prop + u { + __typename + id + b + } + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph1") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + id + b + field(a: $Subgraph1_U_field_a) + } + } + }, + }, + }, + } + `); + expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ + { + kind: 'KeyRenamer', + path: ['..', '..', '... on A', 'prop'], + renameKeyTo: 'Subgraph1_U_field_a', + }, + { + kind: 'KeyRenamer', + path: ['..', '..', '... on B', 'prop'], + renameKeyTo: 'Subgraph1_U_field_a', + }, + ]); + }); }); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 3a3cfed44..d2e930b9f 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1202,7 +1202,7 @@ class FetchGroup { this._contextInputs = []; } other._contextInputs.forEach((r) => { - if (!this._contextInputs!.some((r2) => r2 === r)) { + if (!this._contextInputs!.some((r2) => sameKeyRenamer(r, r2))) { this._contextInputs!.push(r); } }); @@ -1624,7 +1624,7 @@ class FetchGroup { if (!this._contextInputs) { this._contextInputs = []; } - if (!this._contextInputs.some((c) => c.renameKeyTo === renamer.renameKeyTo)) { + if (!this._contextInputs.some((c) => sameKeyRenamer(c, renamer))) { this._contextInputs.push(renamer); } } @@ -5008,3 +5008,15 @@ function operationForQueryFetch( allVariableDefinitions.filter(collectUsedVariables(selectionSet, directives)), /*fragments*/undefined, operationName, directives); } + +const sameKeyRenamer = (k1: FetchDataKeyRenamer, k2: FetchDataKeyRenamer): boolean => { + if (k1.renameKeyTo !== k2.renameKeyTo || k1.path.length !== k2.path.length) { + return false; + } + for (let i = 0; i < k1.path.length; i++) { + if (k1.path[i] !== k2.path[i]) { + return false; + } + } + return true; +} From 87abc6f98dde843ec6cb298145d2d07feebe1277 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 10 May 2024 00:16:09 -0500 Subject: [PATCH 40/82] prettier --- .../src/__tests__/buildPlan.test.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 0c1076421..37c4e8b99 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -9253,7 +9253,7 @@ describe('@fromContext impacts on query planning', () => { }, ]); }); - + it('fromContext with type conditions', () => { const subgraph1 = { name: 'Subgraph1', @@ -9262,20 +9262,20 @@ describe('@fromContext impacts on query planning', () => { type Query { t: I! } - + interface I @context(name: "context") @key(fields: "id") { id: ID! u: U! prop: String! } - - type A implements I @key(fields: "id"){ + + type A implements I @key(fields: "id") { id: ID! u: U! prop: String! } - type B implements I @key(fields: "id"){ + type B implements I @key(fields: "id") { id: ID! u: U! prop: String! @@ -9284,7 +9284,9 @@ describe('@fromContext impacts on query planning', () => { type U @key(fields: "id") { id: ID! b: String! - field(a: String! @fromContext(field: "$context ... on I { prop }")): Int! + field( + a: String! @fromContext(field: "$context ... on I { prop }") + ): Int! } `, }; @@ -9381,7 +9383,7 @@ describe('@fromContext impacts on query planning', () => { }, ]); }); - + it('fromContext with type conditions for union', () => { const subgraph1 = { name: 'Subgraph1', @@ -9390,16 +9392,16 @@ describe('@fromContext impacts on query planning', () => { type Query { t: T! } - + union T @context(name: "context") = A | B - - type A @key(fields: "id"){ + + type A @key(fields: "id") { id: ID! u: U! prop: String! } - type B @key(fields: "id"){ + type B @key(fields: "id") { id: ID! u: U! prop: String! @@ -9408,7 +9410,12 @@ describe('@fromContext impacts on query planning', () => { type U @key(fields: "id") { id: ID! b: String! - field(a: String! @fromContext(field: "$context ... on A { prop } ... on B { prop }")): Int! + field( + a: String! + @fromContext( + field: "$context ... on A { prop } ... on B { prop }" + ) + ): Int! } `, }; From 9bdfd1f0f9096ab9575c7c33d4bca59374e76a47 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 10 May 2024 10:29:13 -0500 Subject: [PATCH 41/82] relative path should ignore FragmentElements --- query-graphs-js/src/graphPath.ts | 9 ++++++++- query-planner-js/src/__tests__/buildPlan.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 97fbd98ea..45f2181fc 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -596,7 +596,14 @@ export class GraphPath { expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ { kind: 'KeyRenamer', - path: ['..', '..', '... on A', 'prop'], + path: ['..', '... on A', 'prop'], renameKeyTo: 'Subgraph1_U_field_a', }, { kind: 'KeyRenamer', - path: ['..', '..', '... on B', 'prop'], + path: ['..', '... on B', 'prop'], renameKeyTo: 'Subgraph1_U_field_a', }, ]); From 69764484f37d97c1e9c5db297d1702b4713e6c5e Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 10 May 2024 11:09:37 -0500 Subject: [PATCH 42/82] get rid of hack --- internals-js/src/operations.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index dfce07398..5a574bb31 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -57,6 +57,7 @@ import { isSubtype, sameType, typesCanBeMerged } from "./types"; import { assert, mapKeys, mapValues, MapWithCachedArrays, MultiMap, SetMultiMap } from "./utils"; import { argumentsEquals, argumentsFromAST, isValidValue, valueToAST, valueToString } from "./values"; import { v1 as uuidv1 } from 'uuid'; +import { CONTEXT_VERSIONS, ContextSpecDefinition } from './specs/contextSpec'; function validate(condition: any, message: () => string, sourceAST?: ASTNode): asserts condition { if (!condition) { @@ -287,13 +288,24 @@ export class Field ex validate(variableDefinitions: VariableDefinitions) { validate(this.name === this.definition.name, () => `Field name "${this.name}" cannot select field "${this.definition.coordinate}: name mismatch"`); - + + // We need to make sure the field has valid values for every non-optional argument. for (const argDef of this.definition.arguments()) { const appliedValue = this.argumentValue(argDef.name); - // TODO: This is a hack that will not work if directives are renamed. Not sure how to fix as we're missing metadata - const isContextualArg = !!argDef.appliedDirectives.find(d => d.name === 'federation__fromContext'); + let isContextualArg = false; + const schema = this.definition.schema(); + const contextFeature = schema.coreFeatures?.getByIdentity(ContextSpecDefinition.identity); + if (contextFeature) { + const contextSpec = CONTEXT_VERSIONS.find(contextFeature.url.version); + if (contextSpec) { + const contextDirective = contextSpec.contextDirective(schema); + if (contextDirective) { + isContextualArg = argDef.appliedDirectivesOf(contextDirective).length > 0; + } + } + } if (appliedValue === undefined) { validate( From 7271c8b47659580ac30ca44318c6c8ead5fa90c9 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 10 May 2024 12:28:59 -0500 Subject: [PATCH 43/82] checkpoint --- internals-js/src/operations.ts | 2 +- query-graphs-js/src/conditionsCaching.ts | 2 +- query-graphs-js/src/graphPath.ts | 9 ++++----- query-planner-js/src/buildPlan.ts | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index 5a574bb31..ee4b476a6 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -175,7 +175,7 @@ export class Field ex withUpdatedArguments(newArgs: TArgs): Field { const newField = new Field( this.definition, - this.args ? this.args.merge(newArgs) : newArgs, + { ...this.args, ...newArgs }, this.appliedDirectives, this.alias, ); diff --git a/query-graphs-js/src/conditionsCaching.ts b/query-graphs-js/src/conditionsCaching.ts index cbcf6b213..051663e0c 100644 --- a/query-graphs-js/src/conditionsCaching.ts +++ b/query-graphs-js/src/conditionsCaching.ts @@ -25,7 +25,7 @@ export function cachingConditionResolver(graph: QueryGraph, resolver: ConditionR // and we currently don't handle that. But we could cache with an empty context, and then apply the proper transformation on the // cached value `pathTree` when the context is not empty. That said, the context is about active @include/@skip and it's not use // that commonly, so this is probably not an urgent improvement. - if (!context.isEmpty() || excludedConditions.length > 0) { + if (!context.isEmpty() || excludedConditions.length > 0 || extraConditions) { return resolver(edge, context, excludedDestinations, excludedConditions, extraConditions); } diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 45f2181fc..90096b5e8 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -585,11 +585,10 @@ export class GraphPath= 0, 'calculated condition index must be positive'); - if (edgeConditions[idx] === null) { - edgeConditions[idx] = entry.pathTree ?? null; - } else if (entry.pathTree !== null) { - // here we need to merge the two OpPathTrees. TODO: Do this all at once - edgeConditions[idx] = entry.pathTree?.merge(edgeConditions[idx]!) ?? null; + + + if (entry.pathTree) { + edgeConditions[idx] = edgeConditions[idx]?.merge(entry.pathTree) ?? entry.pathTree; } if (contextToSelection[idx] === null) { contextToSelection[idx] = new Map(); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index d2e930b9f..71ad90115 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -763,7 +763,7 @@ class QueryPlanningTraversal { ...this.parameters, root: edge.head, }, - (edge.conditions || extraConditions)!, + extraConditions ?? edge.conditions!, 0, false, 'query', From ea94a7e6d6759819a1d51ba64f34a9a7be9ace56 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 10 May 2024 12:36:44 -0500 Subject: [PATCH 44/82] add optional validation --- internals-js/src/operations.ts | 16 ++++++++-------- query-planner-js/src/buildPlan.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index ee4b476a6..bde101401 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -286,7 +286,7 @@ export class Field ex return true; } - validate(variableDefinitions: VariableDefinitions) { + validate(variableDefinitions: VariableDefinitions, validateContextualArgs: boolean) { validate(this.name === this.definition.name, () => `Field name "${this.name}" cannot select field "${this.definition.coordinate}: name mismatch"`); @@ -309,11 +309,11 @@ export class Field ex if (appliedValue === undefined) { validate( - argDef.defaultValue !== undefined || isNullableType(argDef.type!) || isContextualArg, + argDef.defaultValue !== undefined || isNullableType(argDef.type!) || (isContextualArg && !validateContextualArgs), () => `Missing mandatory value for argument "${argDef.name}" of field "${this.definition.coordinate}" in selection "${this}"`); } else { validate( - isValidValue(appliedValue, argDef, variableDefinitions) || isContextualArg, + isValidValue(appliedValue, argDef, variableDefinitions) || (isContextualArg && !validateContextualArgs), () => `Invalid value ${valueToString(appliedValue)} for argument "${argDef.coordinate}" of type ${argDef.type}`) } } @@ -2026,10 +2026,10 @@ export class SelectionSet { return this.selections().every((selection) => selection.canAddTo(parentTypeToTest)); } - validate(variableDefinitions: VariableDefinitions) { + validate(variableDefinitions: VariableDefinitions, validateContextualArgs: boolean = false) { validate(!this.isEmpty(), () => `Invalid empty selection set`); for (const selection of this.selections()) { - selection.validate(variableDefinitions); + selection.validate(variableDefinitions, validateContextualArgs); } } @@ -2548,7 +2548,7 @@ abstract class AbstractSelection, undefined, Fie return predicate(thisWithFilteredSelectionSet) ? thisWithFilteredSelectionSet : undefined; } - validate(variableDefinitions: VariableDefinitions) { - this.element.validate(variableDefinitions); + validate(variableDefinitions: VariableDefinitions, validateContextualArgs: boolean) { + this.element.validate(variableDefinitions, validateContextualArgs); // Note that validation is kind of redundant since `this.selectionSet.validate()` will check that it isn't empty. But doing it // allow to provide much better error messages. validate( diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 71ad90115..f9fa0e4a9 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1533,7 +1533,7 @@ class FetchGroup { const { updated: selection, outputRewrites } = addAliasesForNonMergingFields(selectionWithTypenames); - selection.validate(variableDefinitions); + selection.validate(variableDefinitions, true); return { selection, outputRewrites }; } From ec73f626a56a815fc0d93caf791239060e7126c6 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 10 May 2024 14:52:42 -0500 Subject: [PATCH 45/82] union types broken --- query-graphs-js/src/graphPath.ts | 75 +++++++++++++++---- .../src/__tests__/buildPlan.test.ts | 2 +- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 90096b5e8..a8c43f18c 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -32,11 +32,14 @@ import { parseSelectionSet, Variable, Type, + isScalarType, + isEnumType, + isUnionType, } from "@apollo/federation-internals"; import { OpPathTree, traversePathTree } from "./pathTree"; import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRootType, FEDERATED_GRAPH_ROOT_SOURCE } from "./querygraph"; import { DownCast, Transition } from "./transition"; -import { PathContext, emptyContext } from "./pathContext"; +import { PathContext, emptyContext, isPathContext } from "./pathContext"; import { v4 as uuidv4 } from 'uuid'; const debug = newDebugLogger('path'); @@ -595,14 +598,7 @@ export class GraphPath { (t) => t, pathTransitionToEdge, this.overrideConditions, + getFieldParentTypeForEdge, ); } @@ -1372,6 +1369,7 @@ function advancePathWithNonCollectingAndTypePreservingTransitions TTrigger, triggerToEdge: (graph: QueryGraph, vertex: Vertex, t: TTrigger, overrideConditions: Map) => Edge | null | undefined, overrideConditions: Map, + getFieldParentType: (trigger: TTrigger) => CompositeType | null, ): IndirectPaths { // If we're asked for indirect paths after an "@interfaceObject fake down cast" but that down cast comes just after a non-collecting edges, then // we can ignore it (skip indirect paths from there). The reason is that the presence of the non-collecting just before the fake down-cast means @@ -1498,6 +1496,7 @@ function advancePathWithNonCollectingAndTypePreservingTransitions( } // Additionally, we can only take an edge if we can satisfy its conditions. - const conditionResolution = canSatisfyConditions(path, edge, conditionResolver, emptyContext, [], []); + const conditionResolution = canSatisfyConditions(path, edge, conditionResolver, emptyContext, [], [], getFieldParentTypeForEdge); if (conditionResolution.satisfied) { options.push(path.add(transition, edge, conditionResolution)); } else { @@ -1889,6 +1888,7 @@ function canSatisfyConditions CompositeType | null, ): ConditionResolution { const { conditions, requiredContexts } = edge; if (!conditions && requiredContexts.length === 0) { @@ -1902,11 +1902,31 @@ function canSatisfyConditions(); for (const cxt of requiredContexts) { let level = 1; - for (const [e] of [...path].reverse()) { + for (const [e, trigger] of [...path].reverse()) { if (e !== null && !contextMap.has(cxt.context) && !someSelectionUnsatisfied) { - const parentType = e.head.type; - if (isCompositeType(parentType) && cxt.typesWithContextSet.has(parentType.name)) { - let selectionSet = parseSelectionSet({ parentType: parentType.kind === 'UnionType' ? parentType.types()[0] : parentType, source: cxt.selection }); + // const orig = e.head.type; + // const origMatches = cxt.typesWithContextSet.has(orig.name); + const parentType = getFieldParentType(trigger); + + const matches = Array.from(cxt.typesWithContextSet).some(t => { + if (parentType) { + if (parentType.name === t) { + return true; + } + if (isObjectType(parentType)) { + if (parentType.interfaces().some(i => i.name === t)) { + return true; + } + } + const tInSupergraph = parentType.schema().type(t); + if (tInSupergraph && isUnionType(tInSupergraph)) { + return tInSupergraph.types().some(t => t.name === parentType.name); + } + } + return false; + }); + if (parentType && matches) { + let selectionSet = parseSelectionSet({ parentType, source: cxt.selection }); // If there are multiple FragmentSelections, we want to pick out the one that matches the parentType if (selectionSet.selections().length > 1) { @@ -2042,6 +2062,7 @@ export class SimultaneousPathsWithLazyIndirectPaths { (_t, context) => context, opPathTriggerToEdge, this.overrideConditions, + getFieldParentTypeForOpTrigger, ); } @@ -2779,7 +2800,7 @@ function advanceWithOperation( if (path.tailIsInterfaceObject()) { const fakeDownCastEdge = path.nextEdges().find((e) => e.transition.kind === 'InterfaceObjectFakeDownCast' && e.transition.castedTypeName === typeName); if (fakeDownCastEdge) { - const conditionResolution = canSatisfyConditions(path, fakeDownCastEdge, conditionResolver, context, [], []); + const conditionResolution = canSatisfyConditions(path, fakeDownCastEdge, conditionResolver, context, [], [], getFieldParentTypeForOpTrigger); if (!conditionResolution.satisfied) { return { options: undefined }; } @@ -2807,7 +2828,7 @@ function addFieldEdge( conditionResolver: ConditionResolver, context: PathContext ): OpGraphPath | undefined { - const conditionResolution = canSatisfyConditions(path, edge, conditionResolver, context, [], []); + const conditionResolution = canSatisfyConditions(path, edge, conditionResolver, context, [], [], getFieldParentTypeForOpTrigger); return conditionResolution.satisfied ? path.add(fieldOperation, edge, conditionResolution) : undefined; } @@ -2855,3 +2876,25 @@ function edgeForTypeCast( assert(candidates.length <= 1, () => `Vertex ${vertex} has multiple edges matching ${typeName} (${candidates})`); return candidates.length === 0 ? undefined : candidates[0]; } + +const getFieldParentTypeForOpTrigger = (trigger: OpTrigger): CompositeType | null => { + if (!isPathContext(trigger)) { + if (trigger.kind === 'Field') { + return trigger.definition.parent; + } + } + return null; +}; + +const getFieldParentTypeForEdge = (transition: Transition): CompositeType | null => { + if (transition.kind === 'FieldCollection') { + const type = transition.definition.parent; + if (!type || isScalarType(type) || isEnumType(type)) { + return null; + } + if (isObjectType(type) || isInterfaceType(type) || isUnionType(type)) { + return type; + } + } + return null; +} diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index c2e00551a..4e7284cee 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -9254,7 +9254,7 @@ describe('@fromContext impacts on query planning', () => { ]); }); - it('fromContext with type conditions', () => { + it('fromContext with type conditions interface', () => { const subgraph1 = { name: 'Subgraph1', url: 'https://Subgraph1', From 1f82cf355090a4f06836f2e05b2e4c03cd264901 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Fri, 10 May 2024 16:35:33 -0500 Subject: [PATCH 46/82] Deal with @requires and @fromContext in the same conditions --- query-graphs-js/src/graphPath.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index a8c43f18c..7a2d87ae2 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1895,11 +1895,13 @@ function canSatisfyConditions(); + let contextKeys: OpPathTree | undefined; + if (requiredContexts.length > 0) { // if one of the conditions fails to satisfy, it's ok to bail let someSelectionUnsatisfied = false; - let totalCost = 0; - const contextMap = new Map(); for (const cxt of requiredContexts) { let level = 1; for (const [e, trigger] of [...path].reverse()) { @@ -1969,7 +1971,16 @@ function canSatisfyConditions `Checking conditions ${conditions} on edge ${edge}`); @@ -2004,8 +2015,13 @@ function canSatisfyConditions Date: Sat, 11 May 2024 17:18:09 -0500 Subject: [PATCH 47/82] fix FragmentSelection case (union) --- query-graphs-js/src/graphPath.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 7a2d87ae2..a5ece2b0b 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -35,6 +35,7 @@ import { isScalarType, isEnumType, isUnionType, + SelectionSetUpdates, } from "@apollo/federation-internals"; import { OpPathTree, traversePathTree } from "./pathTree"; import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRootType, FEDERATED_GRAPH_ROOT_SOURCE } from "./querygraph"; @@ -1934,7 +1935,9 @@ function canSatisfyConditions 1) { const fragmentSelection = selectionSet.selections().find(s => s.kind === 'FragmentSelection' && s.element.typeCondition?.name === parentType.name); if (fragmentSelection) { - selectionSet = fragmentSelection.selectionSet!; + const ss = new SelectionSetUpdates(); + ss.add(fragmentSelection); + selectionSet = ss.toSelectionSet(parentType); } } const resolution = conditionResolver(e, context, excludedEdges, excludedConditions, selectionSet); From a6c9a03fffb58fd34ad46e777313932b9c715fc1 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 13:47:38 -0500 Subject: [PATCH 48/82] delete dead code --- query-graphs-js/src/pathTree.ts | 140 +------------------------------- 1 file changed, 1 insertion(+), 139 deletions(-) diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index d5d9b9b2d..e1090cb87 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -1,5 +1,5 @@ import { arrayEquals, assert, copyWitNewLength, mergeMapOrNull, SelectionSet } from "@apollo/federation-internals"; -import { GraphPath, OpGraphPath, OpTrigger, PathIterator, ContextAtUsageEntry } from "./graphPath"; +import { OpGraphPath, OpTrigger, PathIterator, ContextAtUsageEntry } from "./graphPath"; import { Edge, QueryGraph, RootVertex, isRootVertex, Vertex } from "./querygraph"; import { isPathContext } from "./pathContext"; @@ -157,83 +157,6 @@ export class PathTree(graph, currentVertex, localSelections, triggerEquality, childs, mergedContextToSelection, mergedParameterToContext); // TODO: I think this is right? } - // Assumes all root are rooted on the same vertex - static mergeAllOpTrees(graph: QueryGraph, root: RV, trees: OpPathTree[]): OpPathTree { - return this.mergeAllTreesInternal(graph, opTriggerEquality, root, trees); - } - - private static mergeAllTreesInternal( - graph: QueryGraph, - triggerEquality: (t1: TTrigger, t2: TTrigger) => boolean, - currentVertex: RV, - trees: PathTree[] - ): PathTree { - const maxEdges = graph.outEdgesCount(currentVertex); - // We store 'null' edges at `maxEdges` index - const forEdgeIndex: [TTrigger, OpPathTree | null, PathTree[]][][] = new Array(maxEdges + 1); - const newVertices: Vertex[] = new Array(maxEdges); - const order: number[] = new Array(maxEdges + 1); - let localSelections: readonly SelectionSet[] | undefined = undefined; - let currentOrder = 0; - let totalChilds = 0; - for (const tree of trees) { - if (tree.localSelections) { - if (localSelections) { - localSelections = localSelections.concat(tree.localSelections); - } else { - localSelections = tree.localSelections; - } - } - - for (const child of tree.childs) { - const idx = child.index === null ? maxEdges : child.index; - if (!newVertices[idx]) { - newVertices[idx] = child.tree.vertex; - } - const forIndex = forEdgeIndex[idx]; - if (forIndex) { - const triggerIdx = findTriggerIdx(triggerEquality, forIndex, child.trigger); - if (triggerIdx < 0) { - forIndex.push([child.trigger, child.conditions, [child.tree]]); - totalChilds++; - } else { - const existing = forIndex[triggerIdx]; - const existingCond = existing[1]; - const mergedConditions = existingCond ? (child.conditions ? existingCond.mergeIfNotEqual(child.conditions) : existingCond) : child.conditions; - const newTrees = existing[2]; - newTrees.push(child.tree); - forIndex[triggerIdx] = [child.trigger, mergedConditions, newTrees]; - // Note that as we merge, we don't create a new child - } - } else { - // First time we see someone from that index; record the order - order[currentOrder++] = idx; - forEdgeIndex[idx] = [[child.trigger, child.conditions, [child.tree]]]; - totalChilds++; - } - } - } - - const childs: Child[] = new Array(totalChilds); - let idx = 0; - for (let i = 0; i < currentOrder; i++) { - const edgeIndex = order[i]; - const index = (edgeIndex === maxEdges ? null : edgeIndex) as number | TNullEdge; - const newVertex = index === null ? currentVertex : newVertices[edgeIndex]; - const values = forEdgeIndex[edgeIndex]; - for (const [trigger, conditions, subTrees] of values) { - childs[idx++] = { - index, - trigger, - conditions, - tree: this.mergeAllTreesInternal(graph, triggerEquality, newVertex, subTrees) - }; - } - } - assert(idx === totalChilds, () => `Expected to have ${totalChilds} childs but only ${idx} added`); - return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs, null, null); - } - childCount(): number { return this.childs.length; } @@ -370,67 +293,6 @@ export class PathTree): PathTree { - assert(path.graph === this.graph, 'Cannot merge path build on another graph'); - assert(path.root.index === this.vertex.index, () => `Cannot merge path rooted at vertex ${path.root} into tree rooted at other vertex ${this.vertex}`); - return this.mergePathInternal(path[Symbol.iterator]()); - } - - private childsFromPathElements(currentVertex: Vertex, elements: PathIterator): Child[] { - const iterResult = elements.next(); - if (iterResult.done) { - return []; - } - - const [edge, trigger, conditions, contextToSelection, parameterToContext] = iterResult.value; - const edgeIndex = (edge ? edge.index : null) as number | TNullEdge; - currentVertex = edge ? edge.tail : currentVertex; - return [{ - index: edgeIndex, - trigger: trigger, - conditions: conditions, - tree: new PathTree(this.graph, currentVertex, undefined, this.triggerEquality, this.childsFromPathElements(currentVertex, elements), contextToSelection, parameterToContext) - }]; - } - - private mergePathInternal(elements: PathIterator): PathTree { - const iterResult = elements.next(); - if (iterResult.done) { - return this; - } - const [edge, trigger, conditions, contextToSelection, parameterToContext] = iterResult.value; - assert(!edge || edge.head.index === this.vertex.index, () => `Next element head of ${edge} is not equal to current tree vertex ${this.vertex}`); - const edgeIndex = (edge ? edge.index : null) as number | TNullEdge; - const idx = this.findIndex(trigger, edgeIndex); - if (idx < 0) { - const currentVertex = edge ? edge.tail : this.vertex; - return new PathTree( - this.graph, - this.vertex, - undefined, - this.triggerEquality, - this.childs.concat({ - index: edgeIndex, - trigger: trigger, - conditions: conditions, - tree: new PathTree(this.graph, currentVertex, undefined, this.triggerEquality, this.childsFromPathElements(currentVertex, elements), contextToSelection, parameterToContext) - }), - null, - null, - ); - } else { - const newChilds = this.childs.concat(); - const existing = newChilds[idx]; - newChilds[idx] = { - index: existing.index, - trigger: existing.trigger, - conditions: conditions ? (existing.conditions ? existing.conditions.merge(conditions) : conditions) : existing.conditions, - tree: existing.tree.mergePathInternal(elements) - }; - return new PathTree(this.graph, this.vertex, undefined, this.triggerEquality, newChilds, contextToSelection, parameterToContext); - } - } - private findIndex(trigger: TTrigger, edgeIndex: number | TNullEdge): number { for (let i = 0; i < this.childs.length; i++) { const child = this.childs[i]; From 7d561f61756b3b40f7f7b82f0ca02a0075500f87 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 14:27:30 -0500 Subject: [PATCH 49/82] code review updates --- query-graphs-js/src/pathTree.ts | 54 ++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index e1090cb87..a2afa5074 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -270,8 +270,54 @@ export class PathTree): boolean { + const thisKeys = Array.from(this.contextToSelection?.keys() ?? []); + const thatKeys = Array.from(that.contextToSelection?.keys() ?? []); + + if (thisKeys.length !== thatKeys.length) { + return false; + } + + for (const key of thisKeys) { + const thisSelection = this.contextToSelection!.get(key); + const thatSelection = that.contextToSelection!.get(key); + assert(thisSelection, () => `Expected to have a selection for key ${key}`); + + if (!thatSelection || !thisSelection.equals(thatSelection)) { + return false; + } + } + return false; + } + + private parameterToContextEquals(that: PathTree): boolean { + const thisKeys = Array.from(this.parameterToContext?.keys() ?? []); + const thatKeys = Array.from(that.parameterToContext?.keys() ?? []); + + if (thisKeys.length !== thatKeys.length) { + return false; + } + + for (const key of thisKeys) { + const thisSelection = this.parameterToContext!.get(key); + const thatSelection = that.parameterToContext!.get(key); + assert(thisSelection, () => `Expected to have a selection for key ${key}`); + + if (!thatSelection + || (thisSelection.contextId !== thatSelection.contextId) + || !arrayEquals(thisSelection.relativePath, thatSelection.relativePath) + || !thisSelection.selectionSet.equals(thatSelection.selectionSet) + || (thisSelection.subgraphArgType !== thatSelection.subgraphArgType)) { + return false; + } + } + return false; } // Like merge(), this create a new tree that contains the content of both `this` and `other` to this pathTree, but contrarily @@ -288,8 +334,8 @@ export class PathTree Date: Sun, 12 May 2024 18:13:47 -0500 Subject: [PATCH 50/82] updates --- query-graphs-js/src/graphPath.ts | 7 ++- query-graphs-js/src/querygraph.ts | 43 ++++++++++++++++--- .../src/__tests__/buildPlan.test.ts | 22 +++++----- query-planner-js/src/buildPlan.ts | 7 +-- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index a5ece2b0b..ef7cd043f 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1942,7 +1942,12 @@ function canSatisfyConditions `Expected edge to be a FieldCollection edge, got ${edge.transition.kind}`); - const id = `${cxt.subgraphName}_${edge.head.type.name}_${edge.transition.definition.name}_${cxt.namedParameter}`; + + const argIndices = path.graph.subgraphToArgIndices.get(cxt.subgraphName); + assert(argIndices, () => `Expected to find arg indices for subgraph ${cxt.subgraphName}`); + + const id = argIndices.get(cxt.coordinate); + assert(id !== undefined, () => `Expected to find arg index for ${cxt.coordinate}`); contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id, argType: cxt.argType }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; if (resolution.cost === -1 || totalCost === -1) { diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index 08d2acbc1..127a95900 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -131,6 +131,7 @@ export type ContextCondition = { selection: string; typesWithContextSet: Set; argType: Type, + coordinate: string; } /** @@ -357,7 +358,11 @@ export class QueryGraph { * the name identifying them. Note that the `source` string in the `Vertex` of a query graph is guaranteed to be * valid key in this map. */ - readonly sources: ReadonlyMap + readonly sources: ReadonlyMap, + + readonly subgraphToArgs: Map, + + readonly subgraphToArgIndices: Map>, ) { this.nonTrivialFollowupEdges = preComputeNonTrivialFollowupEdges(this); } @@ -879,6 +884,9 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr * Now we'll handle instances of @fromContext. For each argument with @fromContext, I want to add its corresponding * context conditions to the edge corresponding to the argument's field */ + const subgraphToArgs: Map = new Map(); + const subgraphToArgIndicies: Map> = new Map(); + for (const [i, subgraph] of subgraphs.entries()) { const subgraphSchema = schemas[i]; const subgraphMetadata = federationMetadata(subgraphSchema); @@ -903,17 +911,33 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr assert(context, () => `FieldValue has invalid format. Context not found ${field}`); assert(selection, () => `FieldValue has invalid format. Selection not found ${field}`); const namedParameter = application.parent.name; + const argCoordinate = application.parent.coordinate; + const args = subgraphToArgs.get(subgraph.name) ?? []; + args.push(argCoordinate); + subgraphToArgs.set(subgraph.name, args); + const fieldCoordinate = application.parent.parent.coordinate; const typesWithContextSet = contextNameToTypes.get(context); assert(typesWithContextSet, () => `Context ${context} is never set in subgraph`); const z = coordinateMap.get(fieldCoordinate); if (z) { - z.push({ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name, argType: application.parent.type }); + z.push({ namedParameter, coordinate: argCoordinate, context, selection, typesWithContextSet, subgraphName: subgraph.name, argType: application.parent.type }); } else { - coordinateMap.set(fieldCoordinate, [{ namedParameter, context, selection, typesWithContextSet, subgraphName: subgraph.name, argType: application.parent.type }]); + coordinateMap.set(fieldCoordinate, [{ namedParameter, coordinate: argCoordinate, context, selection, typesWithContextSet, subgraphName: subgraph.name, argType: application.parent.type }]); } } - + + for (const [subgraphName, args] of subgraphToArgs) { + args.sort(); + const argToIndex = new Map(); + for (let idx=0; idx < args.length; idx++) { + argToIndex.set(args[idx], `contextualArgument_${i}_${idx}`); + } + subgraphToArgIndicies.set(subgraphName, argToIndex); + } + + builder.setContextMaps(subgraphToArgs, subgraphToArgIndicies); + simpleTraversal( subgraph, _v => { return undefined; }, @@ -1098,6 +1122,8 @@ class GraphBuilder { private readonly typesToVertices: MultiMap = new MultiMap(); private readonly rootVertices: MapWithCachedArrays = new MapWithCachedArrays(); private readonly sources: Map = new Map(); + private subgraphToArgs: Map = new Map(); + private subgraphToArgIndicies: Map> = new Map(); constructor(verticesCount?: number) { this.vertices = verticesCount ? new Array(verticesCount) : []; @@ -1273,7 +1299,14 @@ class GraphBuilder { this.outEdges, this.typesToVertices, this.rootVertices, - this.sources); + this.sources, + this.subgraphToArgs, + this.subgraphToArgIndicies); + } + + setContextMaps(subgraphToArgs: Map, subgraphToArgIndicies: Map>) { + this.subgraphToArgs = subgraphToArgs; + this.subgraphToArgIndicies = subgraphToArgIndicies; } } diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 4e7284cee..2acb8354a 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8912,7 +8912,7 @@ describe('@fromContext impacts on query planning', () => { ... on U { id b - field(a: $Subgraph1_U_field_a) + field(a: $contextualArgument_1_0) } } }, @@ -8924,7 +8924,7 @@ describe('@fromContext impacts on query planning', () => { { kind: 'KeyRenamer', path: ['..', 'prop'], - renameKeyTo: 'Subgraph1_U_field_a', + renameKeyTo: 'contextualArgument_1_0', }, ]); }); @@ -9056,7 +9056,7 @@ describe('@fromContext impacts on query planning', () => { { ... on U { id - field(a: $Subgraph1_U_field_a) + field(a: $contextualArgument_1_0) } } }, @@ -9068,7 +9068,7 @@ describe('@fromContext impacts on query planning', () => { { kind: 'KeyRenamer', path: ['..', 'prop'], - renameKeyTo: 'Subgraph1_U_field_a', + renameKeyTo: 'contextualArgument_1_0', }, ]); }); @@ -9237,7 +9237,7 @@ describe('@fromContext impacts on query planning', () => { ... on U { id b - field(a: $Subgraph1_U_field_a) + field(a: $contextualArgument_1_0) } } }, @@ -9249,7 +9249,7 @@ describe('@fromContext impacts on query planning', () => { { kind: 'KeyRenamer', path: ['..', 'prop'], - renameKeyTo: 'Subgraph1_U_field_a', + renameKeyTo: 'contextualArgument_1_0', }, ]); }); @@ -9367,7 +9367,7 @@ describe('@fromContext impacts on query planning', () => { ... on U { id b - field(a: $Subgraph1_U_field_a) + field(a: $contextualArgument_1_0) } } }, @@ -9379,7 +9379,7 @@ describe('@fromContext impacts on query planning', () => { { kind: 'KeyRenamer', path: ['..', '... on I', 'prop'], - renameKeyTo: 'Subgraph1_U_field_a', + renameKeyTo: 'contextualArgument_1_0', }, ]); }); @@ -9514,7 +9514,7 @@ describe('@fromContext impacts on query planning', () => { ... on U { id b - field(a: $Subgraph1_U_field_a) + field(a: $contextualArgument_1_0) } } }, @@ -9526,12 +9526,12 @@ describe('@fromContext impacts on query planning', () => { { kind: 'KeyRenamer', path: ['..', '... on A', 'prop'], - renameKeyTo: 'Subgraph1_U_field_a', + renameKeyTo: 'contextualArgument_1_0', }, { kind: 'KeyRenamer', path: ['..', '... on B', 'prop'], - renameKeyTo: 'Subgraph1_U_field_a', + renameKeyTo: 'contextualArgument_1_0', }, ]); }); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index f9fa0e4a9..031bb71f8 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -829,6 +829,9 @@ class GroupInputs { for (const otherSelection of other.perType.values()) { this.add(otherSelection.get()); } + for (const [context, type] of other.usedContexts) { + this.addContext(context, type); + } } selectionSets(): SelectionSet[] { @@ -4320,12 +4323,10 @@ function computeGroupsForTree( }); assert(updatedOperation, () => `Extracting @defer from ${operation} should not have resulted in no operation`); - let updated; - const { parameterToContext } = tree; const groupContextSelections = group.contextSelections; - updated = { + const updated = { tree: child, group, path, From 903436b3dea1e09065c8825661ef0d5b707bd8ba Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 18:38:03 -0500 Subject: [PATCH 51/82] subgraph schema in toPlanNode() --- query-planner-js/src/buildPlan.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 031bb71f8..84c9e8598 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1567,7 +1567,7 @@ class FetchGroup { // Note that it won't match the actual type, so we just use String! here as a placeholder for (const [context, type] of this.inputs?.usedContexts ?? []) { assert(isInputType(type), () => `Expected ${type} to be a input type`); - variableDefinitions.add(new VariableDefinition(this.dependencyGraph.supergraphSchema, new Variable(context), type)); + variableDefinitions.add(new VariableDefinition(type.schema(), new Variable(context), type)); } const { selection, outputRewrites } = this.finalizeSelection(variableDefinitions, handledConditions); @@ -4405,9 +4405,7 @@ function computeGroupsForTree( // fetch group or else we need to create a new one if (parameterToContext && groupContextSelections && Array.from(parameterToContext.values()).some(({ contextId }) => groupContextSelections.has(contextId))) { // let's find the edge that will be used as an entry to the new type in the subgraph - const entityVertex = dependencyGraph.federatedQueryGraph.verticesForType(edge.head.type.name).find(v => v.source === edge.tail.source); - assert(entityVertex, () => `Could not find entity entry edge for ${edge.head.source}`); - const keyResolutionEdge = dependencyGraph.federatedQueryGraph.outEdges(entityVertex).find(e => e.transition.kind === 'KeyResolution'); + const keyResolutionEdge = dependencyGraph.federatedQueryGraph.outEdges(edge.head).find(e => e.transition.kind === 'KeyResolution'); assert(keyResolutionEdge, () => `Could not find key resolution edge for ${edge.head.source}`); const type = edge.head.type as CompositeType; From c21a63aaf00f3c69c7478141fc0165b2ece323f8 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 20:45:39 -0500 Subject: [PATCH 52/82] Add contextsToConditionsGroups as a dedicated variable in the computeGroupsForTree stack. This solves a couple problems we were seeing where duplicate values were being requested as well as removing the need for canSatisfyConditions to merge @requires and @fromContext keys --- query-graphs-js/src/graphPath.ts | 15 +- .../src/__tests__/buildPlan.test.ts | 33 +--- query-planner-js/src/buildPlan.ts | 165 ++++++++++++------ 3 files changed, 125 insertions(+), 88 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index ef7cd043f..d823f9159 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1898,7 +1898,6 @@ function canSatisfyConditions(); - let contextKeys: OpPathTree | undefined; if (requiredContexts.length > 0) { // if one of the conditions fails to satisfy, it's ok to bail @@ -1977,17 +1976,15 @@ function canSatisfyConditions e.transition.kind === 'KeyResolution'); assert(keyEdge, () => `Expected to find a key edge from ${edge.head}`); - const r = conditionResolver(keyEdge, context, excludedEdges, excludedConditions, keyEdge.conditions); - debug.log('@fromContext conditions are satisfied, but validating post-require key.'); const postRequireKeyCondition = getLocallySatisfiableKey(path.graph, edge.head); if (!postRequireKeyCondition) { debug.groupEnd('Post-require conditions cannot be satisfied'); return { ...unsatisfiedConditionsResolution, unsatisfiedConditionReason: UnsatisfiedConditionReason.NO_POST_REQUIRE_KEY }; } - contextKeys = r.pathTree; + if (!conditions) { - return { contextMap, cost: totalCost, satisfied: true, pathTree: contextKeys }; + return { contextMap, cost: totalCost, satisfied: true }; } } @@ -2023,13 +2020,9 @@ function canSatisfyConditions { u { __typename id - b } } } @@ -8910,7 +8909,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - id b field(a: $contextualArgument_1_0) } @@ -9009,26 +9007,15 @@ describe('@fromContext impacts on query planning', () => { t { __typename id - } - } - }, - Flatten(path: "t") { - Fetch(service: "Subgraph2") { - { - ... on T { + u { __typename id } - } => - { - ... on T { - prop - } } - }, + } }, Flatten(path: "t") { - Fetch(service: "Subgraph1") { + Fetch(service: "Subgraph2") { { ... on T { __typename @@ -9037,10 +9024,7 @@ describe('@fromContext impacts on query planning', () => { } => { ... on T { - u { - __typename - id - } + prop } } }, @@ -9064,7 +9048,7 @@ describe('@fromContext impacts on query planning', () => { }, } `); - expect((plan as any).node.nodes[3].node.contextRewrites).toEqual([ + expect((plan as any).node.nodes[2].node.contextRewrites).toEqual([ { kind: 'KeyRenamer', path: ['..', 'prop'], @@ -9220,7 +9204,6 @@ describe('@fromContext impacts on query planning', () => { u { __typename id - b } } } @@ -9235,7 +9218,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - id b field(a: $contextualArgument_1_0) } @@ -9350,7 +9332,6 @@ describe('@fromContext impacts on query planning', () => { u { __typename id - b } } } @@ -9365,7 +9346,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - id b field(a: $contextualArgument_1_0) } @@ -9488,7 +9468,6 @@ describe('@fromContext impacts on query planning', () => { u { __typename id - b } } ... on B { @@ -9496,7 +9475,6 @@ describe('@fromContext impacts on query planning', () => { u { __typename id - b } } } @@ -9512,7 +9490,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - id b field(a: $contextualArgument_1_0) } diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 84c9e8598..f8036eab5 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -926,9 +926,6 @@ class FetchGroup { // Set in some code-path to indicate that the selection of the group not be optimized away even if it "looks" useless. mustPreserveSelection: boolean = false; - // context may not be get and set within the same fetch group, so we need to track which contexts are set - contextSelections?: Map; - private constructor( readonly dependencyGraph: FetchDependencyGraph, public index: number, @@ -4029,7 +4026,13 @@ function computeRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTree: // If a type is in a subgraph, it has to be in the supergraph. // A root type has to be a Composite type. const rootTypeInSupergraph = dependencyGraph.supergraphSchemaType(rootType.name) as CompositeType; - computeGroupsForTree(dependencyGraph, child, group, GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), emptyDeferContext); + computeGroupsForTree({ + dependencyGraph, + pathTree: child, + startGroup: group, + initialGroupPath: GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), + initialDeferContext: emptyDeferContext, + }); } return dependencyGraph; } @@ -4043,7 +4046,13 @@ function computeNonRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTr // If a type is in a subgraph, it has to be in the supergraph. // A root type has to be a Composite type. const rootTypeInSupergraph = dependencyGraph.supergraphSchemaType(rootType.name) as CompositeType; - computeGroupsForTree(dependencyGraph, pathTree, group, GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), emptyDeferContext); + computeGroupsForTree({ + dependencyGraph, + pathTree, + startGroup: group, + initialGroupPath: GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), + initialDeferContext: emptyDeferContext, + }); return dependencyGraph; } @@ -4126,29 +4135,41 @@ function updateCreatedGroups(createdGroups: FetchGroup[], ...newCreatedGroups: F } function computeGroupsForTree( - dependencyGraph: FetchDependencyGraph, - pathTree: OpPathTree, - startGroup: FetchGroup, - initialGroupPath: GroupPath, - initialDeferContext: DeferContext, - initialContext: PathContext = emptyContext, -): FetchGroup[] { + { + dependencyGraph, + pathTree, + startGroup, + initialGroupPath, + initialDeferContext, + initialContext = emptyContext, + initialContextsToConditionsGroups = new Map(), + }: { + dependencyGraph: FetchDependencyGraph, + pathTree: OpPathTree, + startGroup: FetchGroup, + initialGroupPath: GroupPath, + initialDeferContext: DeferContext, + initialContext?: PathContext, + initialContextsToConditionsGroups?: Map, +}): FetchGroup[] { const stack: { tree: OpPathTree, group: FetchGroup, path: GroupPath, context: PathContext, deferContext: DeferContext, + contextToConditionsGroups: Map, }[] = [{ tree: pathTree, group: startGroup, path: initialGroupPath, context: initialContext, deferContext: initialDeferContext, + contextToConditionsGroups: initialContextsToConditionsGroups, }]; const createdGroups: FetchGroup[] = [ ]; while (stack.length > 0) { - const { tree, group, path, context, deferContext } = stack.pop()!; + const { tree, group, path, context, deferContext, contextToConditionsGroups } = stack.pop()!; if (tree.localSelections) { for (const selection of tree.localSelections) { group.addAtPath(path.inGroup(), selection); @@ -4171,7 +4192,14 @@ function computeGroupsForTree( if (edge.transition.kind === 'KeyResolution') { assert(conditions, () => `Key edge ${edge} should have some conditions paths`); // First, we need to ensure we fetch the conditions from the current group. - const conditionsGroups = computeGroupsForTree(dependencyGraph, conditions, group, path, deferContextForConditions(deferContext)); + const conditionsGroups = computeGroupsForTree({ + dependencyGraph, + pathTree: conditions, + startGroup: group, + initialGroupPath: path, + initialDeferContext: deferContextForConditions(deferContext), + initialContextsToConditionsGroups: contextToConditionsGroups, + }); updateCreatedGroups(createdGroups, ...conditionsGroups); // Then we can "take the edge", creating a new group. That group depends // on the condition ones. @@ -4229,6 +4257,7 @@ function computeGroupsForTree( path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, edge.tail.type as CompositeType, newContext)), context: newContext, deferContext: updatedDeferContext, + contextToConditionsGroups, }); } else { assert(edge.transition.kind === 'RootTypeResolution', () => `Unexpected non-collecting edge ${edge}`); @@ -4269,6 +4298,7 @@ function computeGroupsForTree( path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, type, newContext)), context: newContext, deferContext: updatedDeferContext, + contextToConditionsGroups, }); } } else if (edge === null) { @@ -4296,6 +4326,7 @@ function computeGroupsForTree( path: newPath, context, deferContext: updatedDeferContext, + contextToConditionsGroups, }); } else { assert(edge.head.source === edge.tail.source, () => `Collecting edge ${edge} for ${operation} should not change the underlying subgraph`) @@ -4324,7 +4355,6 @@ function computeGroupsForTree( assert(updatedOperation, () => `Extracting @defer from ${operation} should not have resulted in no operation`); const { parameterToContext } = tree; - const groupContextSelections = group.contextSelections; const updated = { tree: child, @@ -4332,9 +4362,10 @@ function computeGroupsForTree( path, context, deferContext: updatedDeferContext, + contextToConditionsGroups, }; - if (conditions) { + if (conditions && edge.conditions) { // We have @requires or some other dependency to create groups for. const requireResult = handleRequires( dependencyGraph, @@ -4349,16 +4380,31 @@ function computeGroupsForTree( updated.path = requireResult.path; if (tree.contextToSelection) { - // each of the selections that could be used in a @fromContext parameter should be saved to the fetch group. - // This will also be important in determining when it is necessary to draw a new fetch group boundary - if (updated.group.contextSelections === undefined) { - updated.group.contextSelections = new Map(); - } - for (const [key, value] of tree.contextToSelection) { - updated.group.contextSelections.set(key, value); + const newContextToConditionsGroups = new Map(); + for (const [context] of tree.contextToSelection) { + newContextToConditionsGroups.set(context, [group]); } + updated.contextToConditionsGroups = newContextToConditionsGroups; } updateCreatedGroups(createdGroups, ...requireResult.createdGroups); + } else if (conditions) { + const conditionsGroups = computeGroupsForTree({ + dependencyGraph, + pathTree: conditions, + startGroup: group, + initialGroupPath: path, + initialDeferContext: deferContextForConditions(deferContext), + initialContextsToConditionsGroups: contextToConditionsGroups, + }); + + if (tree.contextToSelection) { + const newContextToConditionsGroups = new Map(); + for (const [context] of tree.contextToSelection) { + newContextToConditionsGroups.set(context, [group, ...conditionsGroups]); + } + updated.contextToConditionsGroups = newContextToConditionsGroups; + } + updateCreatedGroups(createdGroups, ...conditionsGroups); } if (updatedOperation.kind === 'Field' && updatedOperation.name === typenameFieldName) { @@ -4403,21 +4449,43 @@ function computeGroupsForTree( // if we're going to start using context variables, every variable used must be set in a different parent // fetch group or else we need to create a new one - if (parameterToContext && groupContextSelections && Array.from(parameterToContext.values()).some(({ contextId }) => groupContextSelections.has(contextId))) { + if (parameterToContext && Array.from(parameterToContext.values()).some(({ contextId }) => updated.contextToConditionsGroups.get(contextId)?.[0] === group)) { // let's find the edge that will be used as an entry to the new type in the subgraph - const keyResolutionEdge = dependencyGraph.federatedQueryGraph.outEdges(edge.head).find(e => e.transition.kind === 'KeyResolution'); - assert(keyResolutionEdge, () => `Could not find key resolution edge for ${edge.head.source}`); - - const type = edge.head.type as CompositeType; + // const keyResolutionEdge = dependencyGraph.federatedQueryGraph.outEdges(edge.head).find(e => e.transition.kind === 'KeyResolution'); + // assert(keyResolutionEdge, () => `Could not find key resolution edge for ${edge.head.source}`); + + assert(isCompositeType(edge.head.type), () => `Expected a composite type for ${edge.head.type}`); const newGroup = dependencyGraph.getOrCreateKeyFetchGroup({ subgraphName: edge.tail.source, mergeAt: path.inResponse(), - type, + type: edge.head.type, parent: { group, path: path.inGroup() }, conditionsGroups: [], }); + const keyCondition = getLocallySatisfiableKey(dependencyGraph.federatedQueryGraph, edge.head); + assert(keyCondition, () => `canSatisfyConditions() validation should have required a key to be present for ${edge}`); + const keyInputs = newCompositeTypeSelectionSet(edge.head.type).updates().add(keyCondition).toSelectionSet(edge.head.type); + group.addAtPath(path.inGroup(), keyInputs.selections()); + + const inputType = dependencyGraph.typeForFetchInputs(edge.head.type.name); + const inputSelectionSet = newCompositeTypeSelectionSet(inputType).updates().add(keyCondition).toSelectionSet(inputType); + const inputs = wrapInputsSelections(inputType, inputSelectionSet, context); + newGroup.addInputs( + inputs, + computeInputRewritesOnKeyFetch(edge.head.type.name, edge.head.type), + ); + newGroup.addParent({ group, path: path.inGroup() }); + + // all groups that get the contextual variable should be parents of this group + for (const { contextId } of parameterToContext.values()) { + const groups = updated.contextToConditionsGroups.get(contextId); + assert(groups, () => `Could not find groups for context ${contextId}`); + for (const parentGroup of groups) { + newGroup.addParent({ group: parentGroup, path: path.inGroup() }); + } + } for (const [_, { contextId, selectionSet, relativePath, subgraphArgType }] of parameterToContext) { newGroup.addInputContext(contextId, subgraphArgType); const keyRenamers = selectionSetAsKeyRenamers(selectionSet, relativePath, contextId); @@ -4425,32 +4493,19 @@ function computeGroupsForTree( newGroup.addContextRenamer(keyRenamer); } } - tree.parameterToContext = null; - // We also ensure to get the __typename of the current type in the "original" group. - group.addAtPath(path.inGroup().concat(new Field(type.typenameField()!))); - - const inputType = dependencyGraph.typeForFetchInputs(type.name); - const inputSelections = newCompositeTypeSelectionSet(inputType); - inputSelections.updates().add(keyResolutionEdge.conditions!); - newGroup.addInputs( - wrapInputsSelections(inputType, inputSelections.get(), context), // TODO: is the context right - computeInputRewritesOnKeyFetch(inputType.name, type), - ); + // We also ensure to get the __typename of the current type in the "original" group. + // TODO: It may be safe to remove this, but I'm not 100% convinced. Come back and take a look at some point + group.addAtPath(path.inGroup().concat(new Field(edge.head.type.typenameField()!))); updateCreatedGroups(createdGroups, newGroup); - // TODO: - // partition the tree into children with and without triggers. The children with triggers should be processed in the - // child FetchGroup, and those without in the parent. - // if (conditions) { - // stack.push(updated); - // } stack.push({ tree, group: newGroup, - path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, type, context)), + path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, edge.head.type, context)), context, deferContext: updatedDeferContext, + contextToConditionsGroups, }); } else { @@ -4649,7 +4704,13 @@ function handleRequires( }); newGroup.addParent(parent); newGroup.copyInputsOf(group); - const createdGroups = computeGroupsForTree(dependencyGraph, requiresConditions, newGroup, path, deferContextForConditions(deferContext)); + const createdGroups = computeGroupsForTree({ + dependencyGraph, + pathTree: requiresConditions, + startGroup: newGroup, + initialGroupPath: path, + initialDeferContext: deferContextForConditions(deferContext) + }); if (createdGroups.length === 0) { // All conditions were local. Just merge the newly created group back in the current group (we didn't need it) // and continue. @@ -4801,7 +4862,13 @@ function handleRequires( // just after having jumped to that subgraph). In that case, there isn't tons of optimisation we can do: we have to // see what satisfying the @require necessitate, and if it needs anything from another subgraph, we have to stop the // current subgraph fetch there, get the requirements from other subgraphs, and then resume the query of that particular subgraph. - const createdGroups = computeGroupsForTree(dependencyGraph, requiresConditions, group, path, deferContextForConditions(deferContext)); + const createdGroups = computeGroupsForTree({ + dependencyGraph, + pathTree: requiresConditions, + startGroup: group, + initialGroupPath: path, + initialDeferContext: deferContextForConditions(deferContext) + }); // If we didn't created any group, that means the whole condition was fetched from the current group // and we're good. if (createdGroups.length == 0) { From 5ebddec512f4344652d91ec0006285db47ab09c5 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 22:02:10 -0500 Subject: [PATCH 53/82] fix subgraph name transform --- composition-js/src/merging/merge.ts | 32 +++++++++++++---------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index a4664f032..d4d66a455 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -2654,16 +2654,15 @@ class Merger { // TODO: we currently "only" merge together applications that have the exact same arguments (with defaults expanded however), // but when an argument is an input object type, we should (?) ignore those fields that will not be included in the supergraph // due the intersection merging of input types, otherwise the merged value may be invalid for the supergraph. - let perSource: Directive[][] = []; - for (const source of sources) { - if (!source) { - continue; - } - const directives: Directive[] = source.appliedDirectivesOf(name); - if (directives.length > 0) { - perSource.push(directives); + let perSource: { directives: Directive[], subgraphIndex: number}[] = []; + sources.forEach((source, index) => { + if (source) { + const directives: Directive[] = source.appliedDirectivesOf(name); + if (directives.length > 0) { + perSource.push({ directives, subgraphIndex: index }); + } } - } + }); if (perSource.length === 0) { return; @@ -2674,16 +2673,17 @@ class Merger { if (dest.schema().directive(name)?.repeatable) { // For repeatable directives, we simply include each application found but with exact duplicates removed while (perSource.length > 0) { - const directive = this.pickNextDirective(perSource); + const directive = perSource[0].directives[0]; + const subgraphIndex = perSource[0].subgraphIndex; - const transformedArgs = directiveInSupergraph && directiveInSupergraph.staticArgumentTransform && directiveInSupergraph.staticArgumentTransform(this.subgraphs.values()[0], directive.arguments(false)); + const transformedArgs = directiveInSupergraph && directiveInSupergraph.staticArgumentTransform && directiveInSupergraph.staticArgumentTransform(this.subgraphs.values()[subgraphIndex], directive.arguments(false)); dest.applyDirective(directive.name, transformedArgs ?? directive.arguments(false)); // We remove every instances of this particular application. That is we remove any other applicaiton with // the same arguments. Note that when doing so, we include default values. This allows "merging" 2 applications // when one rely on the default value while another don't but explicitely uses that exact default value. perSource = perSource - .map(ds => ds.filter(d => !this.sameDirectiveApplication(directive, d))) - .filter(ds => ds.length); + .map(ds => ({ directives: ds.directives.filter(d => !this.sameDirectiveApplication(directive, d)), subgraphIndex: ds.subgraphIndex })) + .filter(ds => ds.directives.length); } } else { // When non-repeatable, we use a similar strategy than for descriptions: we count the occurence of each _different_ application (different arguments) @@ -2692,7 +2692,7 @@ class Merger { // we'll never warn, but this is handled by the general code below. const differentApplications: Directive[] = []; const counts: number[] = []; - for (const source of perSource) { + for (const { directives: source } of perSource) { assert(source.length === 1, () => `Non-repeatable directive shouldn't have multiple application ${source} in a subgraph`) const application = source[0]; const idx = differentApplications.findIndex((existing) => this.sameDirectiveApplication(existing, application)); @@ -2746,10 +2746,6 @@ class Merger { } } - private pickNextDirective(directives: Directive[][]): Directive { - return directives[0][0]; - } - private sameDirectiveApplication(application1: Directive, application2: Directive): boolean { // Note that when comparing arguments, we include default values. This means that we consider it the same thing (as far as // merging application goes) to rely on a default value or to pass that very exact value explicitely. In theory we From 3ba1b5d8d9ee0797838ff4dd3b1c6caa03e736cb Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 22:40:08 -0500 Subject: [PATCH 54/82] Add test for and fixes the case where the fetchGroup where data is used and where it is set are already different --- query-graphs-js/src/graphPath.ts | 21 +-- .../src/__tests__/buildPlan.test.ts | 125 +++++++++++++++++- query-planner-js/src/buildPlan.ts | 11 +- 3 files changed, 146 insertions(+), 11 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index d823f9159..caa25be2b 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -587,7 +587,7 @@ export class GraphPath= 0, 'calculated condition index must be positive'); @@ -599,7 +599,7 @@ export class GraphPath { if (parentType) { @@ -1947,7 +1947,7 @@ function canSatisfyConditions `Expected to find arg index for ${cxt.coordinate}`); - contextMap.set(cxt.context, { selectionSet, level, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id, argType: cxt.argType }); + contextMap.set(cxt.context, { selectionSet, levelsInDataPath, levelsInQueryPath, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id, argType: cxt.argType }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; if (resolution.cost === -1 || totalCost === -1) { totalCost = -1; @@ -1956,7 +1956,10 @@ function canSatisfyConditions { `, }; - const [api, queryPlanner] = composeAndCreatePlanner(subgraphA, subgraphB); + let api: Schema; + let queryPlanner: QueryPlanner; + + beforeAll(() => { + const [a, b] = composeAndCreatePlanner(subgraphA, subgraphB); + api = a; + queryPlanner = b; + }); test('if directives at the operation level are passed down to subgraph queries', () => { const operation = operationFromDocument( @@ -9057,6 +9065,121 @@ describe('@fromContext impacts on query planning', () => { ]); }); + it('fromContext variable is already in a different fetch group', () => { + const subgraph1 = { + name: 'Subgraph1', + url: 'https://Subgraph1', + typeDefs: gql` + type Query { + t: T! + } + type T @key(fields: "id") { + id: ID! + u: U! + prop: String! + } + type U @key(fields: "id") { + id: ID! + } + `, + }; + + const subgraph2 = { + name: 'Subgraph2', + url: 'https://Subgraph2', + typeDefs: gql` + type Query { + a: Int! + } + + type T @key(fields: "id") @context(name: "context") { + id: ID! + prop: String! @external + } + + type U @key(fields: "id") { + id: ID! + field(a: String! @fromContext(field: "$context { prop }")): Int! + } + `, + }; + + const asFed2Service = (service: ServiceDefinition) => { + return { + ...service, + typeDefs: asFed2SubgraphDocument(service.typeDefs, { + includeAllImports: true, + }), + }; + }; + + const composeAsFed2Subgraphs = (services: ServiceDefinition[]) => { + return composeServices(services.map((s) => asFed2Service(s))); + }; + + const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); + expect(result.errors).toBeUndefined(); + const [api, queryPlanner] = [ + result.schema!.toAPISchema(), + new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + ]; + // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); + const operation = operationFromDocument( + api, + gql` + { + t { + u { + id + field + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "Subgraph1") { + { + t { + prop + u { + __typename + id + } + } + } + }, + Flatten(path: "t.u") { + Fetch(service: "Subgraph2") { + { + ... on U { + __typename + id + } + } => + { + ... on U { + field(a: $contextualArgument_1_0) + } + } + }, + }, + }, + } + `); + expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ + { + kind: 'KeyRenamer', + path: ['..', 'prop'], + renameKeyTo: 'contextualArgument_1_0', + }, + ]); + }); + it.skip('fromContext variable is a list', () => { const subgraph1 = { name: 'Subgraph1', diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index f8036eab5..32df2903f 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -4507,8 +4507,17 @@ function computeGroupsForTree( deferContext: updatedDeferContext, contextToConditionsGroups, }); - } else { + // in this case we can just continue with the current group, but we need to add the context rewrites + if (parameterToContext) { + for (const [_, { selectionSet, relativePath, contextId, subgraphArgType }] of parameterToContext) { + updated.group.addInputContext(contextId, subgraphArgType); + const keyRenamers = selectionSetAsKeyRenamers(selectionSet, relativePath, contextId); + for (const keyRenamer of keyRenamers) { + group.addContextRenamer(keyRenamer); + } + } + } stack.push(updated); } } From 0a592ffcb640ec9b3c3c14af3dd17dcb5e94840e Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 23:17:07 -0500 Subject: [PATCH 55/82] Add hint when removing an argument from the supergraph --- .../src/__tests__/compose.setContext.test.ts | 709 +++++++++--------- composition-js/src/hints.ts | 7 + composition-js/src/merging/merge.ts | 12 +- 3 files changed, 378 insertions(+), 350 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 32a9a6780..8a5d55c4a 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -1,14 +1,11 @@ -import gql from 'graphql-tag'; -import { - assertCompositionSuccess, - composeAsFed2Subgraphs, -} from "./testHelper"; - -describe('setContext tests', () => { - test('vanilla setContext - success case', () => { +import gql from "graphql-tag"; +import { assertCompositionSuccess, composeAsFed2Subgraphs } from "./testHelper"; + +describe("setContext tests", () => { + test("vanilla setContext - success case", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -22,16 +19,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -40,17 +35,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - - test('using a list as input to @fromContext', () => { + + test("using a list as input to @fromContext", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -64,16 +59,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: [String]! @fromContext(field: "$context { prop }") - ): Int! + field(a: [String]! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -82,7 +75,7 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); @@ -131,10 +124,10 @@ describe('setContext tests', () => { // assertCompositionSuccess(result); // }); - it('setContext with multiple contexts (duck typing) - success', () => { + it("setContext with multiple contexts (duck typing) - success", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { foo: Foo! @@ -155,16 +148,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -173,17 +164,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - it('setContext with multiple contexts (duck typing) - type mismatch', () => { + it("setContext with multiple contexts (duck typing) - type mismatch", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { foo: Foo! @@ -204,16 +195,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -222,19 +211,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: the type of the selection does not match the expected type \"String!\"'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection does not match the expected type "String!"' + ); }); - it('setContext with multiple contexts (type conditions) - success', () => { + it("setContext with multiple contexts (type conditions) - success", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { foo: Foo! @@ -255,16 +246,19 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context ... on Foo { prop } ... on Bar { prop2 }") + field( + a: String! + @fromContext( + field: "$context ... on Foo { prop } ... on Bar { prop2 }" + ) ): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -273,17 +267,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - it('context is never set', () => { + it("context is never set", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -297,16 +291,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$unknown { prop }") - ): Int! + field(a: String! @fromContext(field: "$unknown { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -315,19 +307,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"unknown\" is used at location \"U.field(a:)\" but is never set.'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "unknown" is used at location "U.field(a:)" but is never set.' + ); }); - it('resolved field is not available in context', () => { + it("resolved field is not available in context", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -341,16 +335,16 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( + field( a: String! @fromContext(field: "$context { invalidprop }") ): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -359,19 +353,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid for type T. Error: Cannot query field \"invalidprop\" on type \"T\".'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T. Error: Cannot query field "invalidprop" on type "T".' + ); }); - it('context variable does not appear in selection', () => { + it("context variable does not appear in selection", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -385,16 +381,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "{ prop }") - ): Int! + field(a: String! @fromContext(field: "{ prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -403,19 +397,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext argument does not reference a context \"{ prop }\".'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] @fromContext argument does not reference a context "{ prop }".' + ); }); - it('type matches no type conditions', () => { + it("type matches no type conditions", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { bar: Bar! @@ -435,16 +431,16 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( + field( a: String! @fromContext(field: "$context ... on Foo { prop }") ): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -453,19 +449,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: no type condition matches the location \"Bar\"'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: no type condition matches the location "Bar"' + ); }); - it('setContext on interface - success', () => { + it("setContext on interface - success", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { i: I! @@ -483,16 +481,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -501,17 +497,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - it('setContext on interface with type condition - success', () => { + it("setContext on interface with type condition - success", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { i: I! @@ -529,16 +525,16 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( + field( a: String! @fromContext(field: "$context ... on I { prop }") ): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -547,17 +543,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - it('type matches multiple type conditions', () => { + it("type matches multiple type conditions", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { i: I! @@ -575,16 +571,19 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context ... on I { prop } ... on T { prop }") + field( + a: String! + @fromContext( + field: "$context ... on I { prop } ... on T { prop }" + ) ): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -593,17 +592,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - + it("@context works on union when all types have the designated property", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -627,16 +626,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -645,17 +642,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - + it("@context fails on union when type is missing prop", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -678,16 +675,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -696,19 +691,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T2. Error: Cannot query field "prop" on type "T2".'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T2. Error: Cannot query field "prop" on type "T2".' + ); }); - it.todo('type mismatch in context variable'); - it('nullability mismatch is ok if contextual value is non-nullable', () => { + it.todo("type mismatch in context variable"); + it("nullability mismatch is ok if contextual value is non-nullable", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -722,16 +719,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String @fromContext(field: "$context { prop }") - ): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -740,17 +735,17 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - - it('nullability mismatch is not ok if argument is non-nullable', () => { + + it("nullability mismatch is not ok if argument is non-nullable", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -764,16 +759,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -782,18 +775,20 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection does not match the expected type "String!"'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection does not match the expected type "String!"' + ); }); - - it('selection contains more than one value', () => { + + it("selection contains more than one value", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -807,16 +802,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { id prop }") - ): Int! + field(a: String! @fromContext(field: "$context { id prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -825,18 +818,20 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: multiple selections are made'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: multiple selections are made' + ); }); - - it('fields marked @external because of context are not flagged as not used', () => { + + it("fields marked @external because of context are not flagged as not used", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -850,21 +845,19 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! } - + type T @key(fields: "id") { id: ID! prop: String! @@ -873,19 +866,19 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - + // Since it's possible that we have to call into the same subgraph with multiple fetch groups where we would have previously used only one, // we need to verify that there is a resolvable key on the object that uses a context. - it('at least one key on an object that uses a context must be resolvable', () => { + it("at least one key on an object that uses a context must be resolvable", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -899,16 +892,14 @@ describe('setContext tests', () => { type U @key(fields: "id", resolvable: false) { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -917,19 +908,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Object \"U\" has no resolvable key but has an a field with a contextual argument.'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Object "U" has no resolvable key but has an a field with a contextual argument.' + ); }); - - it('context selection contains an alias', () => { + + it("context selection contains an alias", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -943,16 +936,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { foo: prop }") - ): Int! + field(a: String! @fromContext(field: "$context { foo: prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -961,19 +952,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: aliases are not allowed in the selection'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: aliases are not allowed in the selection' + ); }); - - it('context name is invalid', () => { + + it("context name is invalid", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -987,16 +980,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$_context { prop }") - ): Int! + field(a: String! @fromContext(field: "$_context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1005,21 +996,23 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context name \"_context\" may not contain an underscore.'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context name "_context" may not contain an underscore.' + ); }); - - it('context selection contains a query directive', () => { + + it("context selection contains a query directive", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` - directive @foo on FIELD + directive @foo on FIELD type Query { t: T! } @@ -1032,16 +1025,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop @foo }") - ): Int! + field(a: String! @fromContext(field: "$context { prop @foo }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1050,21 +1041,23 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"context\" is used in \"U.field(a:)\" but the selection is invalid: directives are not allowed in the selection'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: directives are not allowed in the selection' + ); }); - - it('context selection references an @interfaceObject', () => { + + it("context selection references an @interfaceObject", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` - directive @foo on FIELD + directive @foo on FIELD type Query { t: T! } @@ -1077,16 +1070,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1095,19 +1086,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Context \"is used in \"U.field(a:)\" but the selection is invalid: One of the types in the selection is an interfaceObject: \"T\"'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] Context "is used in "U.field(a:)" but the selection is invalid: One of the types in the selection is an interfaceObject: "T"' + ); }); - - it('contextual argument is present in multiple subgraphs -- success case', () => { + + it("contextual argument is present in multiple subgraphs -- success case", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -1121,16 +1114,15 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! @shareable + field(a: String! @fromContext(field: "$context { prop }")): Int! + @shareable } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1140,17 +1132,17 @@ describe('setContext tests', () => { id: ID! field: Int! @shareable } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - - it('contextual argument is present in multiple subgraphs, not nullable, no default', () => { + + it("contextual argument is present in multiple subgraphs, not nullable, no default", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -1164,16 +1156,15 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! @shareable + field(a: String! @fromContext(field: "$context { prop }")): Int! + @shareable } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1183,19 +1174,21 @@ describe('setContext tests', () => { id: ID! field(a: String!): Int! @shareable } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('Argument \"U.field(a:)\" is contextual in at least one subgraph but in \"U.field(a:)\" it does not have @fromContext, is not nullable and has no default value.'); + expect(result.errors?.[0].message).toBe( + 'Argument "U.field(a:)" is contextual in at least one subgraph but in "U.field(a:)" it does not have @fromContext, is not nullable and has no default value.' + ); }); - - it('contextual argument is present in multiple subgraphs, nullable', () => { + + it("contextual argument is present in multiple subgraphs, nullable", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -1209,16 +1202,15 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! @shareable + field(a: String! @fromContext(field: "$context { prop }")): Int! + @shareable } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1228,17 +1220,17 @@ describe('setContext tests', () => { id: ID! field(a: String): Int! @shareable } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); }); - - it('contextual argument is present in multiple subgraphs, default value', () => { + + it("contextual argument is present in multiple subgraphs, default value", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -1252,16 +1244,15 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! - field ( - a: String! @fromContext(field: "$context { prop }") - ): Int! @shareable + field(a: String! @fromContext(field: "$context { prop }")): Int! + @shareable } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1271,22 +1262,40 @@ describe('setContext tests', () => { id: ID! field(a: String! = "default"): Int! @shareable } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); assertCompositionSuccess(result); + expect(result.hints).toMatchInlineSnapshot(` + Array [ + CompositionHint { + "coordinate": undefined, + "definition": Object { + "code": "CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS", + "description": "Indicates that the argument will not be present in the supergraph because it is contextual in at least one subgraph.", + "level": Object { + "name": "INFO", + "value": 40, + }, + }, + "element": undefined, + "message": "Contextual argument \\"U.field(a:)\\" will not be included in the supergraph since it is contextual in at least one subgraph", + "nodes": undefined, + }, + ] + `); }); - - it('contextual argument on a directive definition argument', () => { + + it("contextual argument on a directive definition argument", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` directive @foo( a: String! @fromContext(field: "$context { prop }") ) on FIELD_DEFINITION - + type Query { t: T! } @@ -1301,12 +1310,12 @@ describe('setContext tests', () => { id: ID! field: Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1315,19 +1324,21 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext argument cannot be used on a directive definition \"@foo(a:)\".'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] @fromContext argument cannot be used on a directive definition "@foo(a:)".' + ); }); - - it('forbid default values on contextual arguments', () => { + + it("forbid default values on contextual arguments", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! @@ -1342,15 +1353,15 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! field( - a: String! = "default" @fromContext(field: "$context { prop }") + a: String! = "default" @fromContext(field: "$context { prop }") ): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1359,29 +1370,29 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext arguments may not have a default value: \"U.field(a:)\".'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] @fromContext arguments may not have a default value: "U.field(a:)".' + ); }); - - it('forbid contextual arguments on interfaces', () => { + + it("forbid contextual arguments on interfaces", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! } - + interface I @key(fields: "id") { id: ID! - field( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } type T @key(fields: "id") @context(name: "context") { @@ -1392,16 +1403,14 @@ describe('setContext tests', () => { type U implements I @key(fields: "id") { id: ID! - field( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1410,24 +1419,26 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] @fromContext argument cannot be used on a field that exists on an interface \"I.field(a:)\".'); + expect(result.errors?.[0].message).toBe( + '[Subgraph1] @fromContext argument cannot be used on a field that exists on an interface "I.field(a:)".' + ); }); - - it('forbid contextual arguments on interfaces', () => { + + it("forbid contextual arguments on interfaces", () => { const subgraph1 = { - name: 'Subgraph1', - utl: 'https://Subgraph1', + name: "Subgraph1", + utl: "https://Subgraph1", typeDefs: gql` type Query { t: T! } - + interface I @key(fields: "id") { id: ID! field: Int! @@ -1441,16 +1452,14 @@ describe('setContext tests', () => { type U implements I @key(fields: "id") { id: ID! - field( - a: String! @fromContext(field: "$context { prop }") - ): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } - ` + `, }; const subgraph2 = { - name: 'Subgraph2', - utl: 'https://Subgraph2', + name: "Subgraph2", + utl: "https://Subgraph2", typeDefs: gql` type Query { a: Int! @@ -1459,12 +1468,14 @@ describe('setContext tests', () => { type U @key(fields: "id") { id: ID! } - ` + `, }; const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe('[Subgraph1] Field U.field includes required argument a that is missing from the Interface field I.field.'); + expect(result.errors?.[0].message).toBe( + "[Subgraph1] Field U.field includes required argument a that is missing from the Interface field I.field." + ); }); }); diff --git a/composition-js/src/hints.ts b/composition-js/src/hints.ts index 11fafcf69..482a9f269 100644 --- a/composition-js/src/hints.ts +++ b/composition-js/src/hints.ts @@ -212,6 +212,12 @@ const IMPLICITLY_UPGRADED_FEDERATION_VERSION = makeCodeDefinition({ + ' In this case, the supergraph uses the federation version required by the directive.' }); +const CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS = makeCodeDefinition({ + code: 'CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS', + level: HintLevel.INFO, + description: 'Indicates that the argument will not be present in the supergraph because it is contextual in at least one subgraph.' +}); + export const HINTS = { INCONSISTENT_BUT_COMPATIBLE_FIELD_TYPE, INCONSISTENT_BUT_COMPATIBLE_ARGUMENT_TYPE, @@ -242,6 +248,7 @@ export const HINTS = { DIRECTIVE_COMPOSITION_WARN, INCONSISTENT_RUNTIME_TYPES_FOR_SHAREABLE_RETURN, IMPLICITLY_UPGRADED_FEDERATION_VERSION, + CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS, } export class CompositionHint { diff --git a/composition-js/src/merging/merge.ts b/composition-js/src/merging/merge.ts index d4d66a455..de8fa2877 100644 --- a/composition-js/src/merging/merge.ts +++ b/composition-js/src/merging/merge.ts @@ -79,6 +79,7 @@ import { CoreFeature, Subgraph, StaticArgumentsTransform, + isNullableType, } from "@apollo/federation-internals"; import { ASTNode, GraphQLError, DirectiveLocation } from "graphql"; import { @@ -1919,9 +1920,18 @@ class Merger { `Argument "${arg.coordinate}" is contextual in at least one subgraph but in "${argument.coordinate}" it does not have @fromContext, is not nullable and has no default value.`, { nodes: sourceASTs(sources[idx]?.argument(argName)) }, )); - + } + + if (!isContextual && argument && argType && (isNullableType(argType) || argument.defaultValue !== undefined)) { + // in this case, we want to issue a hint that the argument will not be present in the supergraph schema + this.mismatchReporter.pushHint(new CompositionHint( + HINTS.CONTEXTUAL_ARGUMENT_NOT_CONTEXTUAL_IN_ALL_SUBGRAPHS, + `Contextual argument "${argument.coordinate}" will not be included in the supergraph since it is contextual in at least one subgraph`, + undefined, + )); } }); + arg.remove(); continue; } From eee34b5917494627246b30650ee5cdc40e71eb45 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Sun, 12 May 2024 23:29:50 -0500 Subject: [PATCH 56/82] remove some dead code --- internals-js/src/operations.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index bde101401..7e7be942e 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -21,11 +21,9 @@ import { Directive, DirectiveTargetElement, FieldDefinition, - InterfaceType, isCompositeType, isInterfaceType, isNullableType, - ObjectType, runtimeTypesIntersects, Schema, SchemaRootKind, @@ -234,13 +232,7 @@ export class Field ex }; }); } - - - appliesTo(type: ObjectType | InterfaceType): boolean { - const definition = type.field(this.name); - return !!definition && this.selects(definition); - } - + selects( definition: FieldDefinition, assumeValid: boolean = false, From b0008062e7d3872dcf147057211fa571326fa9c7 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 00:16:49 -0500 Subject: [PATCH 57/82] move contextToSelection to Child from OpPathTree --- internals-js/src/utils.ts | 25 +++++++++++++ query-graphs-js/src/graphPath.ts | 14 ++++---- query-graphs-js/src/pathTree.ts | 59 +++++++++++-------------------- query-planner-js/src/buildPlan.ts | 10 +++--- 4 files changed, 57 insertions(+), 51 deletions(-) diff --git a/internals-js/src/utils.ts b/internals-js/src/utils.ts index fa1bce8ab..8e80779c7 100644 --- a/internals-js/src/utils.ts +++ b/internals-js/src/utils.ts @@ -451,3 +451,28 @@ export function mergeMapOrNull(m1: Map | null, m2: Map | null): } return new Map([...m1, ...m2]); } + +export function composeSets(s1: Set | null, s2: Set | null): Set | null { + if (!s1 && !s2) { + return null; + } + const result = new Set(); + s1?.forEach(v => result.add(v)); + s2?.forEach(v => result.add(v)); + return result; +} + +export function setsEqual(s1: Set | null, s2: Set | null): boolean { + if (!s1 && !s2) { + return true; + } + if (!s1 || !s2 || s1.size !== s2.size) { + return false; + } + for (const key of s1) { + if (!s2.has(key)) { + return false; + } + } + return true; +} diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index caa25be2b..fa4e589e5 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -176,13 +176,13 @@ type PathProps | null)[], + readonly contextToSelection: readonly (Set | null)[], /** This parameter is for mapping contexts back to the parameter used to collect the field */ readonly parameterToContext: readonly (Map | null)[], } -export class GraphPath implements Iterable<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { +export class GraphPath implements Iterable<[Edge | TNullEdge, TTrigger, OpPathTree | null, Set | null, Map | null]> { private constructor( private readonly props: PathProps, ) { @@ -384,7 +384,7 @@ export class GraphPath | null, Map | null]> { + next(): IteratorResult<[Edge | TNullEdge, TTrigger, OpPathTree | null, Set | null, Map | null]> { if (this.currentIndex >= path.size) { return { done: true, value: undefined }; } @@ -569,7 +569,7 @@ export class GraphPath | null)[], + contextToSelection: (Set | null)[], parameterToContext: (Map | null)[], }{ const edgeConditions = this.props.edgeConditions.concat(conditionsResolution.pathTree ?? null); @@ -595,9 +595,9 @@ export class GraphPath(); + contextToSelection[idx] = new Set(); } - contextToSelection[idx]?.set(entry.id, entry.selectionSet); + contextToSelection[idx]?.add(entry.id); parameterToContext[parameterToContext.length-1]?.set(entry.paramName, { contextId: entry.id, relativePath: Array(entry.levelsInDataPath).fill(".."), selectionSet: entry.selectionSet, subgraphArgType: entry.argType } ); } @@ -895,7 +895,7 @@ export class GraphPath extends Iterator<[Edge | TNullEdge, TTrigger, OpPathTree | null, Map | null, Map | null]> { +export interface PathIterator extends Iterator<[Edge | TNullEdge, TTrigger, OpPathTree | null, Set | null, Map | null]> { currentIndex: number, currentVertex: Vertex } diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index a2afa5074..a467912bd 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -1,4 +1,4 @@ -import { arrayEquals, assert, copyWitNewLength, mergeMapOrNull, SelectionSet } from "@apollo/federation-internals"; +import { arrayEquals, assert, composeSets, copyWitNewLength, mergeMapOrNull, SelectionSet, setsEqual } from "@apollo/federation-internals"; import { OpGraphPath, OpTrigger, PathIterator, ContextAtUsageEntry } from "./graphPath"; import { Edge, QueryGraph, RootVertex, isRootVertex, Vertex } from "./querygraph"; import { isPathContext } from "./pathContext"; @@ -20,12 +20,13 @@ type Child = { index: number | TNullEdge, trigger: TTrigger, conditions: OpPathTree | null, - tree: PathTree + tree: PathTree, + contextToSelection: Set | null, } function findTriggerIdx( triggerEquality: (t1: TTrigger, t2: TTrigger) => boolean, - forIndex: [TTrigger, OpPathTree | null, TElements, Map | null, Map | null][] | [TTrigger, OpPathTree | null, TElements][], + forIndex: [TTrigger, OpPathTree | null, TElements, Set | null, Map | null][] | [TTrigger, OpPathTree | null, TElements][], trigger: TTrigger ): number { for (let i = 0; i < forIndex.length; i++) { @@ -48,7 +49,6 @@ export class PathTree boolean, private readonly childs: Child[], - readonly contextToSelection: Map | null, public parameterToContext: Map | null, ) { } @@ -58,7 +58,7 @@ export class PathTree boolean ): PathTree { - return new PathTree(graph, root, undefined, triggerEquality, [], null, null); + return new PathTree(graph, root, undefined, triggerEquality, [], null); } static createOp(graph: QueryGraph, root: RV): OpPathTree { @@ -88,7 +88,7 @@ export class PathTree { const maxEdges = graph.outEdgesCount(currentVertex); // We store 'null' edges at `maxEdges` index - const forEdgeIndex: [TTrigger, OpPathTree | null, IterAndSelection[], Map | null, Map | null][][] = new Array(maxEdges + 1); + const forEdgeIndex: [TTrigger, OpPathTree | null, IterAndSelection[], Set | null, Map | null][][] = new Array(maxEdges + 1); const newVertices: Vertex[] = new Array(maxEdges); const order: number[] = new Array(maxEdges + 1); let currentOrder = 0; @@ -118,7 +118,7 @@ export class PathTree | null = null; + let mergedContextToSelection: Set | null = null; let mergedParameterToContext: Map | null = null; const childs: Child[] = new Array(totalChilds); @@ -143,18 +143,19 @@ export class PathTree `Expected to have ${totalChilds} childs but only ${idx} added`); - return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs, mergedContextToSelection, mergedParameterToContext); // TODO: I think this is right? + return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs, mergedParameterToContext); // TODO: I think this is right? } childCount(): number { @@ -165,7 +166,7 @@ export class PathTree], void, undefined> { + *childElements(reverseOrder: boolean = false): Generator<[Edge | TNullEdge, TTrigger, OpPathTree | null, PathTree, Set | null], void, undefined> { if (reverseOrder) { for (let i = this.childs.length - 1; i >= 0; i--) { yield this.element(i); @@ -177,13 +178,14 @@ export class PathTree] { + private element(i: number): [Edge | TNullEdge, TTrigger, OpPathTree | null, PathTree, Set | null] { const child = this.childs[i]; return [ (child.index === null ? null : this.graph.outEdge(this.vertex, child.index)) as Edge | TNullEdge, child.trigger, child.conditions, - child.tree + child.tree, + child.contextToSelection, ]; } @@ -194,7 +196,8 @@ export class PathTree `Expected ${newSize} childs but only got ${addIdx}`); - return new PathTree(this.graph, this.vertex, localSelections, this.triggerEquality, newChilds, mergedContextToSelection, mergedParameterToContext); + return new PathTree(this.graph, this.vertex, localSelections, this.triggerEquality, newChilds, mergedParameterToContext); } private equalsSameRoot(that: PathTree): boolean { @@ -271,31 +273,11 @@ export class PathTree): boolean { - const thisKeys = Array.from(this.contextToSelection?.keys() ?? []); - const thatKeys = Array.from(that.contextToSelection?.keys() ?? []); - - if (thisKeys.length !== thatKeys.length) { - return false; - } - - for (const key of thisKeys) { - const thisSelection = this.contextToSelection!.get(key); - const thatSelection = that.contextToSelection!.get(key); - assert(thisSelection, () => `Expected to have a selection for key ${key}`); - - if (!thatSelection || !thisSelection.equals(thatSelection)) { - return false; - } - } - return false; - } - private parameterToContextEquals(that: PathTree): boolean { const thisKeys = Array.from(this.parameterToContext?.keys() ?? []); const thatKeys = Array.from(that.parameterToContext?.keys() ?? []); @@ -334,9 +316,8 @@ export class PathTree(); - for (const [context] of tree.contextToSelection) { + for (const context of contextToSelection) { newContextToConditionsGroups.set(context, [group]); } updated.contextToConditionsGroups = newContextToConditionsGroups; @@ -4397,9 +4397,9 @@ function computeGroupsForTree( initialContextsToConditionsGroups: contextToConditionsGroups, }); - if (tree.contextToSelection) { + if (contextToSelection) { const newContextToConditionsGroups = new Map(); - for (const [context] of tree.contextToSelection) { + for (const context of contextToSelection) { newContextToConditionsGroups.set(context, [group, ...conditionsGroups]); } updated.contextToConditionsGroups = newContextToConditionsGroups; From 3c44f7a96611454a24139463dcd89900f09ef0db Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 01:00:37 -0500 Subject: [PATCH 58/82] Move parameterToContext onto child --- query-graphs-js/src/pathTree.ts | 40 ++++++++++++++----------------- query-planner-js/src/buildPlan.ts | 8 +------ 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index a467912bd..5fb7c1f06 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -22,6 +22,7 @@ type Child = { conditions: OpPathTree | null, tree: PathTree, contextToSelection: Set | null, + parameterToContext: Map | null, } function findTriggerIdx( @@ -49,7 +50,6 @@ export class PathTree boolean, private readonly childs: Child[], - public parameterToContext: Map | null, ) { } @@ -58,7 +58,7 @@ export class PathTree boolean ): PathTree { - return new PathTree(graph, root, undefined, triggerEquality, [], null); + return new PathTree(graph, root, undefined, triggerEquality, []); } static createOp(graph: QueryGraph, root: RV): OpPathTree { @@ -132,9 +132,6 @@ export class PathTree | null = null; - let mergedParameterToContext: Map | null = null; - const childs: Child[] = new Array(totalChilds); let idx = 0; for (let i = 0; i < currentOrder; i++) { @@ -142,20 +139,19 @@ export class PathTree `Expected to have ${totalChilds} childs but only ${idx} added`); - return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs, mergedParameterToContext); // TODO: I think this is right? + return new PathTree(graph, currentVertex, localSelections, triggerEquality, childs); } childCount(): number { @@ -166,7 +162,7 @@ export class PathTree, Set | null], void, undefined> { + *childElements(reverseOrder: boolean = false): Generator<[Edge | TNullEdge, TTrigger, OpPathTree | null, PathTree, Set | null, Map | null], void, undefined> { if (reverseOrder) { for (let i = this.childs.length - 1; i >= 0; i--) { yield this.element(i); @@ -178,7 +174,7 @@ export class PathTree, Set | null] { + private element(i: number): [Edge | TNullEdge, TTrigger, OpPathTree | null, PathTree, Set | null, Map | null] { const child = this.childs[i]; return [ (child.index === null ? null : this.graph.outEdge(this.vertex, child.index)) as Edge | TNullEdge, @@ -186,6 +182,7 @@ export class PathTree `Expected ${newSize} childs but only got ${addIdx}`); - return new PathTree(this.graph, this.vertex, localSelections, this.triggerEquality, newChilds, mergedParameterToContext); + return new PathTree(this.graph, this.vertex, localSelections, this.triggerEquality, newChilds); } private equalsSameRoot(that: PathTree): boolean { @@ -274,21 +271,21 @@ export class PathTree): boolean { - const thisKeys = Array.from(this.parameterToContext?.keys() ?? []); - const thatKeys = Array.from(that.parameterToContext?.keys() ?? []); + private static parameterToContextEquals(ptc1: Map | null, ptc2: Map | null): boolean { + const thisKeys = Array.from(ptc1?.keys() ?? []); + const thatKeys = Array.from(ptc2?.keys() ?? []); if (thisKeys.length !== thatKeys.length) { return false; } for (const key of thisKeys) { - const thisSelection = this.parameterToContext!.get(key); - const thatSelection = that.parameterToContext!.get(key); + const thisSelection = ptc1!.get(key); + const thatSelection = ptc2!.get(key); assert(thisSelection, () => `Expected to have a selection for key ${key}`); if (!thatSelection @@ -316,8 +313,7 @@ export class PathTree `Extracting @defer from ${operation} should not have resulted in no operation`); - const { parameterToContext } = tree; - const updated = { tree: child, group, @@ -4450,10 +4448,6 @@ function computeGroupsForTree( // if we're going to start using context variables, every variable used must be set in a different parent // fetch group or else we need to create a new one if (parameterToContext && Array.from(parameterToContext.values()).some(({ contextId }) => updated.contextToConditionsGroups.get(contextId)?.[0] === group)) { - // let's find the edge that will be used as an entry to the new type in the subgraph - // const keyResolutionEdge = dependencyGraph.federatedQueryGraph.outEdges(edge.head).find(e => e.transition.kind === 'KeyResolution'); - // assert(keyResolutionEdge, () => `Could not find key resolution edge for ${edge.head.source}`); - assert(isCompositeType(edge.head.type), () => `Expected a composite type for ${edge.head.type}`); const newGroup = dependencyGraph.getOrCreateKeyFetchGroup({ subgraphName: edge.tail.source, From d90b818c6a0582567aad839cd0a05a2532876516 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 01:00:51 -0500 Subject: [PATCH 59/82] fix spelling --- query-graphs-js/src/querygraph.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/query-graphs-js/src/querygraph.ts b/query-graphs-js/src/querygraph.ts index 127a95900..5596d2cad 100644 --- a/query-graphs-js/src/querygraph.ts +++ b/query-graphs-js/src/querygraph.ts @@ -885,7 +885,7 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr * context conditions to the edge corresponding to the argument's field */ const subgraphToArgs: Map = new Map(); - const subgraphToArgIndicies: Map> = new Map(); + const subgraphToArgIndices: Map> = new Map(); for (const [i, subgraph] of subgraphs.entries()) { const subgraphSchema = schemas[i]; @@ -933,10 +933,10 @@ function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGr for (let idx=0; idx < args.length; idx++) { argToIndex.set(args[idx], `contextualArgument_${i}_${idx}`); } - subgraphToArgIndicies.set(subgraphName, argToIndex); + subgraphToArgIndices.set(subgraphName, argToIndex); } - builder.setContextMaps(subgraphToArgs, subgraphToArgIndicies); + builder.setContextMaps(subgraphToArgs, subgraphToArgIndices); simpleTraversal( subgraph, @@ -1123,7 +1123,7 @@ class GraphBuilder { private readonly rootVertices: MapWithCachedArrays = new MapWithCachedArrays(); private readonly sources: Map = new Map(); private subgraphToArgs: Map = new Map(); - private subgraphToArgIndicies: Map> = new Map(); + private subgraphToArgIndices: Map> = new Map(); constructor(verticesCount?: number) { this.vertices = verticesCount ? new Array(verticesCount) : []; @@ -1301,12 +1301,12 @@ class GraphBuilder { this.rootVertices, this.sources, this.subgraphToArgs, - this.subgraphToArgIndicies); + this.subgraphToArgIndices); } - setContextMaps(subgraphToArgs: Map, subgraphToArgIndicies: Map>) { + setContextMaps(subgraphToArgs: Map, subgraphToArgIndices: Map>) { this.subgraphToArgs = subgraphToArgs; - this.subgraphToArgIndicies = subgraphToArgIndicies; + this.subgraphToArgIndices = subgraphToArgIndices; } } From fcff867443b319454df64a543d4c287bfbaa1da3 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 08:26:58 -0500 Subject: [PATCH 60/82] update snapshots --- query-planner-js/src/__tests__/buildPlan.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 854d13ddc..b7a69555b 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8903,6 +8903,7 @@ describe('@fromContext impacts on query planning', () => { u { __typename id + b } } } @@ -9327,6 +9328,7 @@ describe('@fromContext impacts on query planning', () => { u { __typename id + b } } } @@ -9455,6 +9457,7 @@ describe('@fromContext impacts on query planning', () => { u { __typename id + b } } } @@ -9591,6 +9594,7 @@ describe('@fromContext impacts on query planning', () => { u { __typename id + b } } ... on B { @@ -9598,6 +9602,7 @@ describe('@fromContext impacts on query planning', () => { u { __typename id + b } } } From 6eadd464ed902550c107053cdb3ee58e4d38a674 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 11:57:23 -0700 Subject: [PATCH 61/82] Update loop detection in satisfiability checks --- composition-js/src/validate.ts | 170 +++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 60 deletions(-) diff --git a/composition-js/src/validate.ts b/composition-js/src/validate.ts index 9c51cc428..1e6a04311 100644 --- a/composition-js/src/validate.ts +++ b/composition-js/src/validate.ts @@ -12,9 +12,11 @@ import { isAbstractType, isCompositeType, isDefined, + isInterfaceType, isLeafType, isNullableType, isObjectType, + isUnionType, joinStrings, MultiMap, newDebugLogger, @@ -34,6 +36,9 @@ import { VariableDefinitions, isOutputType, JoinFieldDirectiveArguments, + ContextSpecDefinition, + CONTEXT_VERSIONS, + NamedSchemaElement, } from "@apollo/federation-internals"; import { Edge, @@ -106,7 +111,7 @@ function shareableFieldNonIntersectingRuntimeTypesError( + `\nShared field "${field.coordinate}" return type "${field.type}" has a non-intersecting set of possible runtime types across subgraphs. Runtime types in subgraphs are:` + `\n${typeStrings.join(';\n')}.` + `\nThis is not allowed as shared fields must resolve the same way in all subgraphs, and that imply at least some common runtime types between the subgraphs.`; - const error = new ValidationError(message, invalidState.supergraphPath, invalidState.subgraphPaths.map((p) => p.path), witness); + const error = new ValidationError(message, invalidState.supergraphPath, invalidState.subgraphPathInfos.map((p) => p.path.path), witness); return ERRORS.SHAREABLE_HAS_MISMATCHED_RUNTIME_TYPES.err(error.message, { nodes: subgraphNodes(invalidState, (s) => (s.type(field.parent.name) as CompositeType | undefined)?.field(field.name)?.sourceAST), }); @@ -311,44 +316,6 @@ export function validateGraphComposition( return errors.length > 0 ? { errors, hints } : { hints }; } -// TODO: we don't use this anywhere, can we just remove it? -export function computeSubgraphPaths( - supergraphSchema: Schema, - supergraphPath: RootPath, - federatedQueryGraph: QueryGraph, - overrideConditions: Map, -): { - traversal?: ValidationState, - isComplete?: boolean, - error?: GraphQLError -} { - try { - assert(!supergraphPath.hasAnyEdgeConditions(), () => `A supergraph path should not have edge condition paths (as supergraph edges should not have conditions): ${supergraphPath}`); - const conditionResolver = simpleValidationConditionResolver({ supergraph: supergraphSchema, queryGraph: federatedQueryGraph, withCaching: true }); - const initialState = ValidationState.initial({ supergraphAPI: supergraphPath.graph, kind: supergraphPath.root.rootKind, federatedQueryGraph, conditionResolver, overrideConditions }); - const context = new ValidationContext(supergraphSchema); - let state = initialState; - let isIncomplete = false; - for (const [edge] of supergraphPath) { - const { state: updated, error } = state.validateTransition(context, edge); - if (error) { - throw error; - } - if (!updated) { - isIncomplete = true; - break; - } - state = updated; - } - return {traversal: state, isComplete: !isIncomplete}; - } catch (error) { - if (error instanceof GraphQLError) { - return {error}; - } - throw error; - } -} - function initialSubgraphPaths(kind: SchemaRootKind, subgraphs: QueryGraph): RootPath[] { const root = subgraphs.root(kind); assert(root, () => `The supergraph shouldn't have a ${kind} root if no subgraphs have one`); @@ -375,6 +342,7 @@ export function extractValidationError(error: any): ValidationError | undefined export class ValidationContext { private readonly joinTypeDirective: DirectiveDefinition; private readonly joinFieldDirective: DirectiveDefinition; + private readonly typesToContexts: Map> constructor( readonly supergraphSchema: Schema, @@ -382,6 +350,38 @@ export class ValidationContext { const [_, joinSpec] = validateSupergraph(supergraphSchema); this.joinTypeDirective = joinSpec.typeDirective(supergraphSchema); this.joinFieldDirective = joinSpec.fieldDirective(supergraphSchema); + + this.typesToContexts = new Map(); + let contextDirective: DirectiveDefinition<{ name: string }> | undefined; + const contextFeature = supergraphSchema.coreFeatures?.getByIdentity(ContextSpecDefinition.identity); + if (contextFeature) { + const contextSpec = CONTEXT_VERSIONS.find(contextFeature.url.version); + assert(contextSpec, `Unexpected context spec version ${contextFeature.url.version}`); + contextDirective = contextSpec.contextDirective(supergraphSchema); + } + + for (const application of contextDirective?.applications() ?? []) { + const { name: context } = application.arguments(); + assert( + application.parent instanceof NamedSchemaElement, + "Unexpectedly found unnamed element with @context" + ); + const type = supergraphSchema.type(application.parent.name); + assert(type, `Type ${application.parent.name} unexpectedly doesn't exist`); + const type_names = [type.name]; + if (isInterfaceType(type)) { + type_names.push(...type.allImplementations().map((t) => t.name)); + } else if (isUnionType(type)) { + type_names.push(...type.types().map((t) => t.name)); + } + for (const type_name of type_names) { + if (this.typesToContexts.has(type_name)) { + this.typesToContexts.get(type_name)!.add(context); + } else { + this.typesToContexts.set(type_name, new Set([context])); + } + } + } } isShareable(field: FieldDefinition): boolean { @@ -404,6 +404,16 @@ export class ValidationContext { return !args.external && !args.usedOverridden; }).length > 1); } + + matchingContexts(type_name: string): string[] { + return [...(this.typesToContexts.get(type_name) ?? [])]; + } +} + +type SubgraphPathInfo = { + path: TransitionPathWithLazyIndirectPaths, + // The key for this map is the context name in the supergraph schema. + contexts: Map, } export class ValidationState { @@ -411,7 +421,7 @@ export class ValidationState { // Path in the supergraph corresponding to the current state. public readonly supergraphPath: RootPath, // All the possible paths we could be in the subgraph. - public readonly subgraphPaths: TransitionPathWithLazyIndirectPaths[], + public readonly subgraphPathInfos: SubgraphPathInfo[], // When we encounter an `@override`n field with a label condition, we record // its value (T/F) as we traverse the graph. This allows us to ignore paths // that can never be taken by the query planner (i.e. a path where the @@ -441,7 +451,10 @@ export class ValidationState { conditionResolver, overrideConditions, ), - ), + ).map((p) => ({ + path: p, + contexts: new Map(), + })), ); } @@ -456,7 +469,7 @@ export class ValidationState { * to a type condition for which there cannot be any runtime types), in which case not further validation is necessary "from that branch". * Additionally, when the state can be successfully advanced, an `hint` can be optionally returned. */ - validateTransition(context: ValidationContext, supergraphEdge: Edge): { + validateTransition(context: ValidationContext, supergraphEdge: Edge, matchingContexts: string[]): { state?: ValidationState, error?: GraphQLError, hint?: CompositionHint, @@ -465,7 +478,7 @@ export class ValidationState { const transition = supergraphEdge.transition; const targetType = supergraphEdge.tail.type; - const newSubgraphPaths: TransitionPathWithLazyIndirectPaths[] = []; + const newSubgraphPathInfos: SubgraphPathInfo[] = []; const deadEnds: Unadvanceables[] = []; // If the edge has an override condition, we should capture it in the state so // that we can ignore later edges that don't satisfy the condition. @@ -477,7 +490,7 @@ export class ValidationState { ); } - for (const path of this.subgraphPaths) { + for (const { path, contexts } of this.subgraphPathInfos) { const options = advancePathWithTransition( path, transition, @@ -493,16 +506,32 @@ export class ValidationState { // type condition give us no matching results, and so we can handle whatever comes next really. return { state: undefined }; } - newSubgraphPaths.push(...options); + let newContexts = contexts; + if (matchingContexts.length) { + const subgraph_name = path.path.tail.source; + const type_name = path.path.tail.type.name; + newContexts = new Map([...contexts]); + for (const matchingContext in matchingContexts) { + newContexts.set( + matchingContext, + { + subgraph_name, + type_name, + } + ) + } + } + + newSubgraphPathInfos.push(...options.map((p) => ({ path: p, contexts: newContexts }))); } const newPath = this.supergraphPath.add(transition, supergraphEdge, noConditionsResolution); - if (newSubgraphPaths.length === 0) { - return { error: satisfiabilityError(newPath, this.subgraphPaths.map((p) => p.path), deadEnds) }; + if (newSubgraphPathInfos.length === 0) { + return { error: satisfiabilityError(newPath, this.subgraphPathInfos.map((p) => p.path.path), deadEnds) }; } const updatedState = new ValidationState( newPath, - newSubgraphPaths, + newSubgraphPathInfos, newOverrideConditions, ); @@ -524,12 +553,12 @@ export class ValidationState { // implementations so they never are a problem for this check and can be ignored. let hint: CompositionHint | undefined = undefined; if ( - newSubgraphPaths.length > 1 + newSubgraphPathInfos.length > 1 && transition.kind === 'FieldCollection' && isAbstractType(newPath.tail.type) && context.isShareable(transition.definition) ) { - const filteredPaths = newSubgraphPaths.map((p) => p.path).filter((p) => isAbstractType(p.tail.type)); + const filteredPaths = newSubgraphPathInfos.map((p) => p.path.path).filter((p) => isAbstractType(p.tail.type)); if (filteredPaths.length > 1) { // We start our intersection by using all the supergraph types, both because it's a convenient "max" set to start our intersection, // but also because that means we will ignore @inaccessible types in our checks (which is probably not very important because @@ -540,7 +569,7 @@ export class ValidationState { const runtimeTypesToSubgraphs = new MultiMap(); const runtimeTypesPerSubgraphs = new MultiMap(); let hasAllEmpty = true; - for (const path of newSubgraphPaths) { + for (const { path } of newSubgraphPathInfos) { const subgraph = path.path.tail.source; const typeNames = possibleRuntimeTypeNamesSorted(path.path); runtimeTypesPerSubgraphs.set(subgraph, typeNames); @@ -579,8 +608,8 @@ export class ValidationState { currentSubgraphNames(): string[] { const subgraphs: string[] = []; - for (const path of this.subgraphPaths) { - const source = path.path.tail.source; + for (const pathInfo of this.subgraphPathInfos) { + const source = pathInfo.path.path.tail.source; if (!subgraphs.includes(source)) { subgraphs.push(source); } @@ -588,23 +617,41 @@ export class ValidationState { return subgraphs; } + currentSubgraphContextKeys(): Set { + const subgraphContextKeys: Set = new Set(); + for (const pathInfo of this.subgraphPathInfos) { + const subgraphName = pathInfo.path.path.tail.source; + const entryKeys = []; + const contexts = Array.from(pathInfo.contexts.entries()); + contexts.sort((a, b) => a[0].localeCompare(b[0])); + for (const [context, { subgraph_name, type_name }] of contexts) { + entryKeys.push(`${context}=${subgraph_name}.${type_name}`); + } + subgraphContextKeys.add( + `${subgraphName}[${entryKeys.join(',')}]` + ); + } + return subgraphContextKeys; + } + currentSubgraphs(): { name: string, schema: Schema }[] { - if (this.subgraphPaths.length === 0) { + if (this.subgraphPathInfos.length === 0) { return []; } - const sources = this.subgraphPaths[0].path.graph.sources; + const sources = this.subgraphPathInfos[0].path.path.graph.sources; return this.currentSubgraphNames().map((name) => ({ name, schema: sources.get(name)!})); } toString(): string { - return `${this.supergraphPath} <=> [${this.subgraphPaths.map(s => s.toString()).join(', ')}]`; + return `${this.supergraphPath} <=> [${this.subgraphPathInfos.map(s => s.path.toString()).join(', ')}]`; } } // `maybeSuperset` is a superset (or equal) if it contains all of `other`'s // subgraphs and all of `other`'s labels (with matching conditions). function isSupersetOrEqual(maybeSuperset: VertexVisit, other: VertexVisit): boolean { - const includesAllSubgraphs = other.subgraphs.every((s) => maybeSuperset.subgraphs.includes(s)); + const includesAllSubgraphs = [...other.subgraphContextKeys] + .every((s) => maybeSuperset.subgraphContextKeys.has(s)); const includesAllOverrideConditions = [...other.overrideConditions.entries()].every(([label, value]) => maybeSuperset.overrideConditions.get(label) === value ); @@ -613,7 +660,7 @@ function isSupersetOrEqual(maybeSuperset: VertexVisit, other: VertexVisit): bool } interface VertexVisit { - subgraphs: string[]; + subgraphContextKeys: Set; overrideConditions: Map; } @@ -667,7 +714,7 @@ class ValidationTraversal { const vertex = state.supergraphPath.tail; const currentVertexVisit: VertexVisit = { - subgraphs: state.currentSubgraphNames(), + subgraphContextKeys: state.currentSubgraphContextKeys(), overrideConditions: state.selectedOverrideConditions }; const previousVisitsForVertex = this.previousVisits.getVertexState(vertex); @@ -711,9 +758,12 @@ class ValidationTraversal { continue; } + const matchingContexts = edge.transition.kind === 'FieldCollection' + ? this.context.matchingContexts(edge.head.type.name) + : []; debug.group(() => `Validating supergraph edge ${edge}`); - const { state: newState, error, hint } = state.validateTransition(this.context, edge); + const { state: newState, error, hint } = state.validateTransition(this.context, edge, matchingContexts); if (error) { debug.groupEnd(`Validation error!`); this.validationErrors.push(error); From fa91a789fc903eae907d8897df8dcff8dfa37686 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 12:01:56 -0700 Subject: [PATCH 62/82] Update advancePathWithDirectTransition() error messaging to account for contexts not being set in the GraphPath --- query-graphs-js/src/graphPath.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index fa4e589e5..77788ada1 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1729,6 +1729,11 @@ function advancePathWithDirectTransition( const parentTypeInSubgraph = path.graph.sources.get(edge.head.source)!.type(field.parent.name)! as CompositeType; const details = conditionResolution.unsatisfiedConditionReason === UnsatisfiedConditionReason.NO_POST_REQUIRE_KEY ? `@require condition on field "${field.coordinate}" can be satisfied but missing usable key on "${parentTypeInSubgraph}" in subgraph "${edge.head.source}" to resume query` + : conditionResolution.unsatisfiedConditionReason === UnsatisfiedConditionReason.NO_CONTEXT_SET + ? `could not find a match for required context for field "${field.coordinate}"` + // TODO: This isn't necessarily just because an @requires + // condition was unsatisified, but could also be because a + // @fromContext condition was unsatisified. : `cannot satisfy @require conditions on field "${field.coordinate}"${warnOnKeyFieldsMarkedExternal(parentTypeInSubgraph)}`; deadEnds.push({ sourceSubgraph: edge.head.source, From dcd870bb4a12182bf91f8cffefed18e511050a8f Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 12:07:12 -0700 Subject: [PATCH 63/82] Use camelCase instead of snake_case --- composition-js/src/validate.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/composition-js/src/validate.ts b/composition-js/src/validate.ts index 1e6a04311..534cdebd5 100644 --- a/composition-js/src/validate.ts +++ b/composition-js/src/validate.ts @@ -368,17 +368,17 @@ export class ValidationContext { ); const type = supergraphSchema.type(application.parent.name); assert(type, `Type ${application.parent.name} unexpectedly doesn't exist`); - const type_names = [type.name]; + const typeNames = [type.name]; if (isInterfaceType(type)) { - type_names.push(...type.allImplementations().map((t) => t.name)); + typeNames.push(...type.allImplementations().map((t) => t.name)); } else if (isUnionType(type)) { - type_names.push(...type.types().map((t) => t.name)); + typeNames.push(...type.types().map((t) => t.name)); } - for (const type_name of type_names) { - if (this.typesToContexts.has(type_name)) { - this.typesToContexts.get(type_name)!.add(context); + for (const typeName of typeNames) { + if (this.typesToContexts.has(typeName)) { + this.typesToContexts.get(typeName)!.add(context); } else { - this.typesToContexts.set(type_name, new Set([context])); + this.typesToContexts.set(typeName, new Set([context])); } } } @@ -405,15 +405,15 @@ export class ValidationContext { }).length > 1); } - matchingContexts(type_name: string): string[] { - return [...(this.typesToContexts.get(type_name) ?? [])]; + matchingContexts(typeName: string): string[] { + return [...(this.typesToContexts.get(typeName) ?? [])]; } } type SubgraphPathInfo = { path: TransitionPathWithLazyIndirectPaths, // The key for this map is the context name in the supergraph schema. - contexts: Map, + contexts: Map, } export class ValidationState { @@ -508,15 +508,15 @@ export class ValidationState { } let newContexts = contexts; if (matchingContexts.length) { - const subgraph_name = path.path.tail.source; - const type_name = path.path.tail.type.name; + const subgraphName = path.path.tail.source; + const typeName = path.path.tail.type.name; newContexts = new Map([...contexts]); for (const matchingContext in matchingContexts) { newContexts.set( matchingContext, { - subgraph_name, - type_name, + subgraphName, + typeName, } ) } @@ -624,8 +624,8 @@ export class ValidationState { const entryKeys = []; const contexts = Array.from(pathInfo.contexts.entries()); contexts.sort((a, b) => a[0].localeCompare(b[0])); - for (const [context, { subgraph_name, type_name }] of contexts) { - entryKeys.push(`${context}=${subgraph_name}.${type_name}`); + for (const [context, { subgraphName, typeName }] of contexts) { + entryKeys.push(`${context}=${subgraphName}.${typeName}`); } subgraphContextKeys.add( `${subgraphName}[${entryKeys.join(',')}]` From 5cd6477461fe12e61226f700df71b46720751e87 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 12:18:49 -0700 Subject: [PATCH 64/82] Update validate() for arguments to check @fromContext on subgraphs instead of @context in supergraphs --- internals-js/src/operations.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index 7e7be942e..4d80d98f5 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -49,13 +49,12 @@ import { directivesToString, directivesToDirectiveNodes, } from "./definitions"; -import { isInterfaceObjectType } from "./federation"; +import { federationMetadata, isFederationDirectiveDefinedInSchema, isInterfaceObjectType } from "./federation"; import { ERRORS } from "./error"; import { isSubtype, sameType, typesCanBeMerged } from "./types"; import { assert, mapKeys, mapValues, MapWithCachedArrays, MultiMap, SetMultiMap } from "./utils"; import { argumentsEquals, argumentsFromAST, isValidValue, valueToAST, valueToString } from "./values"; import { v1 as uuidv1 } from 'uuid'; -import { CONTEXT_VERSIONS, ContextSpecDefinition } from './specs/contextSpec'; function validate(condition: any, message: () => string, sourceAST?: ASTNode): asserts condition { if (!condition) { @@ -288,24 +287,18 @@ export class Field ex let isContextualArg = false; const schema = this.definition.schema(); - const contextFeature = schema.coreFeatures?.getByIdentity(ContextSpecDefinition.identity); - if (contextFeature) { - const contextSpec = CONTEXT_VERSIONS.find(contextFeature.url.version); - if (contextSpec) { - const contextDirective = contextSpec.contextDirective(schema); - if (contextDirective) { - isContextualArg = argDef.appliedDirectivesOf(contextDirective).length > 0; - } - } + const fromContextDirective = federationMetadata(schema)?.fromContextDirective(); + if (fromContextDirective && isFederationDirectiveDefinedInSchema(fromContextDirective)) { + isContextualArg = argDef.appliedDirectivesOf(fromContextDirective).length > 0; } if (appliedValue === undefined) { validate( - argDef.defaultValue !== undefined || isNullableType(argDef.type!) || (isContextualArg && !validateContextualArgs), + (isContextualArg && !validateContextualArgs) || argDef.defaultValue !== undefined || isNullableType(argDef.type!), () => `Missing mandatory value for argument "${argDef.name}" of field "${this.definition.coordinate}" in selection "${this}"`); } else { validate( - isValidValue(appliedValue, argDef, variableDefinitions) || (isContextualArg && !validateContextualArgs), + (isContextualArg && !validateContextualArgs) || isValidValue(appliedValue, argDef, variableDefinitions), () => `Invalid value ${valueToString(appliedValue)} for argument "${argDef.coordinate}" of type ${argDef.type}`) } } From 8f0544e0fbb17a83aa1b88e6e462129e8370d667 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 12:34:56 -0700 Subject: [PATCH 65/82] Fix argument types in context spec --- internals-js/src/specs/contextSpec.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index a0ced7bbd..3d122f020 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -6,7 +6,7 @@ import { FeatureUrl, FeatureVersion, } from "./coreSpec"; -import { DirectiveDefinition, NonNullType, Schema } from "../definitions"; +import { DirectiveDefinition, NonNullType, Schema, isInputType } from "../definitions"; import { DirectiveSpecification, createDirectiveSpecification, createScalarTypeSpecification } from "../directiveAndTypeSpecification"; import { registerKnownFeature } from "../knownCoreFeatures"; import { Subgraph } from '../federation'; @@ -40,16 +40,9 @@ export class ContextSpecDefinition extends FeatureDefinition { this.contextDirectiveSpec = createDirectiveSpecification({ name: ContextDirectiveName.CONTEXT, locations: [DirectiveLocation.INTERFACE, DirectiveLocation.OBJECT, DirectiveLocation.UNION], - args: [{ name: 'name', type: (schema, feature) => { - assert(feature, "Shouldn't be added without being attached to a @link spec"); - const fieldValue = feature.typeNameInSchema(fieldValueScalar); - const fieldValueType = schema.type(fieldValue); - assert(fieldValueType, () => `Expected "${fieldValue}" to be defined`); - return new NonNullType(fieldValueType); - }}], + args: [{ name: 'name', type: (schema) => new NonNullType(schema.stringType())}], composes: true, repeatable: true, - supergraphSpecification: (fedVersion) => CONTEXT_VERSIONS.getMinimumRequiredVersion(fedVersion), staticArgumentTransform: (subgraph: Subgraph, args: {[key: string]: any}) => { const subgraphName = subgraph.name; return { @@ -61,7 +54,14 @@ export class ContextSpecDefinition extends FeatureDefinition { this.fromContextDirectiveSpec = createDirectiveSpecification({ name: ContextDirectiveName.FROM_CONTEXT, locations: [DirectiveLocation.ARGUMENT_DEFINITION], - args: [{ name: 'field', type: (schema) => schema.stringType() }], + args: [{ name: 'field', type: (schema, feature) => { + assert(feature, "Shouldn't be added without being attached to a @link spec"); + const fieldValue = feature.typeNameInSchema(fieldValueScalar); + const fieldValueType = schema.type(fieldValue); + assert(fieldValueType, () => `Expected "${fieldValue}" to be defined`); + assert(isInputType(fieldValueType), `Expected "${fieldValue}" to be an input type`); + return fieldValueType; + }}], composes: false, }); From 4830903217811ed109f3cb3225a0cd641f5ed3bc Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 12:44:12 -0700 Subject: [PATCH 66/82] Update names, and remove dead code from specs files --- internals-js/src/federation.ts | 4 ---- internals-js/src/specs/federationSpec.ts | 2 +- internals-js/src/specs/joinSpec.ts | 12 ++++++------ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 1845250fd..80d41ed2e 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -1309,10 +1309,6 @@ export class FederationMetadata { return this.schema.type(this.federationTypeNameInSchema(FederationTypeName.FIELD_SET)) as ScalarType; } - singleFieldSelectionType(): ScalarType { - return this.schema.type(this.federationTypeNameInSchema(FederationTypeName.FIELD_VALUE)) as ScalarType; - } - allFederationTypes(): NamedType[] { // We manually include the `_Any`, `_Service` and `Entity` types because there are not strictly // speaking part of the federation @link spec. diff --git a/internals-js/src/specs/federationSpec.ts b/internals-js/src/specs/federationSpec.ts index 51e0d4a25..16adeb26b 100644 --- a/internals-js/src/specs/federationSpec.ts +++ b/internals-js/src/specs/federationSpec.ts @@ -25,7 +25,7 @@ export const federationIdentity = 'https://specs.apollo.dev/federation'; export enum FederationTypeName { FIELD_SET = 'FieldSet', - FIELD_VALUE = 'FieldValue', + CONTEXT_FIELD_VALUE = 'ContextFieldValue', } export enum FederationDirectiveName { diff --git a/internals-js/src/specs/joinSpec.ts b/internals-js/src/specs/joinSpec.ts index 1a064d65b..bd45fa212 100644 --- a/internals-js/src/specs/joinSpec.ts +++ b/internals-js/src/specs/joinSpec.ts @@ -168,13 +168,13 @@ export class JoinSpecDefinition extends FeatureDefinition { // set context // there are no renames that happen within the join spec, so this is fine // note that join spec will only used in supergraph schema - const requireType = schema.addType(new InputObjectType('join__ContextArgument')); - requireType.addField('name', new NonNullType(schema.stringType())); - requireType.addField('type', new NonNullType(schema.stringType())); - requireType.addField('context', new NonNullType(schema.stringType())); - requireType.addField('selection', new NonNullType(fieldValue)); + const contextArgumentsType = schema.addType(new InputObjectType('join__ContextArgument')); + contextArgumentsType.addField('name', new NonNullType(schema.stringType())); + contextArgumentsType.addField('type', new NonNullType(schema.stringType())); + contextArgumentsType.addField('context', new NonNullType(schema.stringType())); + contextArgumentsType.addField('selection', new NonNullType(fieldValue)); - joinField.addArgument('contextArguments', new ListType(new NonNullType(requireType))); + joinField.addArgument('contextArguments', new ListType(new NonNullType(contextArgumentsType))); } if (this.isV01()) { From f7a35c08edd832c82a51ab606c127b2b94cf35ce Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 12:56:24 -0700 Subject: [PATCH 67/82] Remove context spec from gateway supported features, and add list of router supported features --- internals-js/src/supergraphs.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index 6539362b5..a24309fb2 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -20,6 +20,25 @@ export const DEFAULT_SUPPORTED_SUPERGRAPH_FEATURES = new Set([ 'https://specs.apollo.dev/tag/v0.3', 'https://specs.apollo.dev/inaccessible/v0.1', 'https://specs.apollo.dev/inaccessible/v0.2', +]); + +export const ROUTER_SUPPORTED_SUPERGRAPH_FEATURES = new Set([ + 'https://specs.apollo.dev/core/v0.1', + 'https://specs.apollo.dev/core/v0.2', + 'https://specs.apollo.dev/join/v0.1', + 'https://specs.apollo.dev/join/v0.2', + 'https://specs.apollo.dev/join/v0.3', + 'https://specs.apollo.dev/join/v0.4', + 'https://specs.apollo.dev/join/v0.5', + 'https://specs.apollo.dev/tag/v0.1', + 'https://specs.apollo.dev/tag/v0.2', + 'https://specs.apollo.dev/tag/v0.3', + 'https://specs.apollo.dev/inaccessible/v0.1', + 'https://specs.apollo.dev/inaccessible/v0.2', + 'https://specs.apollo.dev/authenticated/v0.1', + 'https://specs.apollo.dev/requiresScopes/v0.1', + 'https://specs.apollo.dev/policy/v0.1', + 'https://specs.apollo.dev/source/v0.1', 'https://specs.apollo.dev/context/v0.1', ]); From cc55f8afb3c5d32e114e0ebf7ee325b7d8d574ea Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 15:44:51 -0500 Subject: [PATCH 68/82] Add function Supergraph.buildForTests() that will build using router features --- internals-js/src/specs/contextSpec.ts | 1 + internals-js/src/supergraphs.ts | 3 +++ query-planner-js/src/__tests__/buildPlan.test.ts | 14 +++++++------- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/internals-js/src/specs/contextSpec.ts b/internals-js/src/specs/contextSpec.ts index 3d122f020..5fd3ad873 100644 --- a/internals-js/src/specs/contextSpec.ts +++ b/internals-js/src/specs/contextSpec.ts @@ -43,6 +43,7 @@ export class ContextSpecDefinition extends FeatureDefinition { args: [{ name: 'name', type: (schema) => new NonNullType(schema.stringType())}], composes: true, repeatable: true, + supergraphSpecification: (fedVersion) => CONTEXT_VERSIONS.getMinimumRequiredVersion(fedVersion), staticArgumentTransform: (subgraph: Subgraph, args: {[key: string]: any}) => { const subgraphName = subgraph.name; return { diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index a24309fb2..c26faa9d5 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -135,6 +135,9 @@ export class Supergraph { return new Supergraph(schema, options?.supportedFeatures, options?.validateSupergraph); } + static buildForTests(supergraphSdl: string | DocumentNode, validateSupergraph?: boolean) { + return Supergraph.build(supergraphSdl, { supportedFeatures: ROUTER_SUPPORTED_SUPERGRAPH_FEATURES, validateSupergraph }); + } /** * The list of names/urls of the subgraphs contained in this subgraph. * diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index b7a69555b..32bf6cc75 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8875,7 +8875,7 @@ describe('@fromContext impacts on query planning', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); const [api, queryPlanner] = [ result.schema!.toAPISchema(), - new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), ]; // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); const operation = operationFromDocument( @@ -8990,7 +8990,7 @@ describe('@fromContext impacts on query planning', () => { expect(result.errors).toBeUndefined(); const [api, queryPlanner] = [ result.schema!.toAPISchema(), - new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), ]; // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); const operation = operationFromDocument( @@ -9122,7 +9122,7 @@ describe('@fromContext impacts on query planning', () => { expect(result.errors).toBeUndefined(); const [api, queryPlanner] = [ result.schema!.toAPISchema(), - new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), ]; // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); const operation = operationFromDocument( @@ -9230,7 +9230,7 @@ describe('@fromContext impacts on query planning', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); const [api, queryPlanner] = [ result.schema!.toAPISchema(), - new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), ]; // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); const operation = operationFromDocument( @@ -9300,7 +9300,7 @@ describe('@fromContext impacts on query planning', () => { const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); const [api, queryPlanner] = [ result.schema!.toAPISchema(), - new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), ]; // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); const operation = operationFromDocument( @@ -9428,7 +9428,7 @@ describe('@fromContext impacts on query planning', () => { expect(result.errors).toBeUndefined(); const [api, queryPlanner] = [ result.schema!.toAPISchema(), - new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), ]; // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); const operation = operationFromDocument( @@ -9556,7 +9556,7 @@ describe('@fromContext impacts on query planning', () => { expect(result.errors).toBeUndefined(); const [api, queryPlanner] = [ result.schema!.toAPISchema(), - new QueryPlanner(Supergraph.build(result.supergraphSdl!)), + new QueryPlanner(Supergraph.buildForTests(result.supergraphSdl!)), ]; // const [api, queryPlanner] = composeFed2SubgraphsAndCreatePlanner(subgraph1, subgraph2); const operation = operationFromDocument( From c14a8730916694c172096037ca228fc8bd44e7a8 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 15:03:18 -0700 Subject: [PATCH 69/82] Update subgraph validation to account for object type conditions --- internals-js/src/federation.ts | 183 ++++++++++++++++++++++----------- 1 file changed, 121 insertions(+), 62 deletions(-) diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 80d41ed2e..6e8329fb0 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -34,6 +34,8 @@ import { isNonNullType, isLeafType, isListType, + isWrapperType, + possibleRuntimeTypes, } from "./definitions"; import { assert, MultiMap, printHumanReadableList, OrderedMap, mapValues, assertUnreachable } from "./utils"; import { SDLValidationRule } from "graphql/validation/ValidationContext"; @@ -98,7 +100,6 @@ import { SourceFieldDirectiveArgs, SourceTypeDirectiveArgs, } from "./specs/sourceSpec"; -import { isSubtype } from './types'; const linkSpec = LINK_VERSIONS.latest(); const tagSpec = TAG_VERSIONS.latest(); @@ -414,8 +415,9 @@ const validateFieldValueType = ({ assert(element.definition.type, 'Element type definition should exist'); const type = element.definition.type; if (childSelectionSet) { + assert(isCompositeType(type), 'Child selection sets should only exist on composite types'); const { resolvedType } = validateFieldValueType({ - currentType, + currentType: type, selectionSet: childSelectionSet, errorCollector, metadata, @@ -427,7 +429,12 @@ const validateFieldValueType = ({ return { resolvedType: wrapResolvedType({ originalType: type, resolvedType}) }; } assert(isLeafType(baseType(type)), 'Expected a leaf type'); - return { resolvedType: type as InputType }; + return { + resolvedType: wrapResolvedType({ + originalType: type, + resolvedType: baseType(type) as InputType + }) + }; }); return typesArray.reduce((acc, { resolvedType }) => { if (acc.resolvedType?.toString() === resolvedType?.toString()) { @@ -448,7 +455,10 @@ const validateSelectionFormat = ({ fromContextParent: ArgumentDefinition>, errorCollector: GraphQLError[], }): { - selectionType: 'error' | 'field' | 'inlineFragment', + selectionType: 'error' | 'field', +} | { + selectionType: 'inlineFragment', + typeConditions: Set, } => { // we only need to parse the selection once, not do it for each location try { @@ -474,7 +484,7 @@ const validateSelectionFormat = ({ } return { selectionType: 'field' }; } else if (firstSelectionKind === 'InlineFragment') { - const inlineFragmentTypeConditionMap: Map = new Map(); + const inlineFragmentTypeConditions: Set = new Set(); if (!selections.every((s) => s.kind === 'InlineFragment')) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple fields could be selected`, @@ -486,17 +496,20 @@ const validateSelectionFormat = ({ assert(s.kind === 'InlineFragment', 'Expected an inline fragment'); const { typeCondition }= s; if (typeCondition) { - inlineFragmentTypeConditionMap.set(typeCondition.name.value, false); + inlineFragmentTypeConditions.add(typeCondition.name.value); } }); - if (inlineFragmentTypeConditionMap.size !== selections.length) { + if (inlineFragmentTypeConditions.size !== selections.length) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type conditions have same name`, { nodes: sourceASTs(fromContextParent) } )); return { selectionType: 'error' }; } - return { selectionType: 'inlineFragment' }; + return { + selectionType: 'inlineFragment', + typeConditions: inlineFragmentTypeConditions, + }; } else if (firstSelectionKind === 'FragmentSpread') { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: fragment spread is not allowed`, @@ -517,17 +530,20 @@ const validateSelectionFormat = ({ } // implementation of spec https://spec.graphql.org/draft/#IsValidImplementationFieldType() -function isValidImplementationFieldType(fieldType: NamedType | InputType, implementedFieldType: NamedType | InputType): boolean { +function isValidImplementationFieldType(fieldType: InputType, implementedFieldType: InputType): boolean { if (isNonNullType(fieldType)) { if (isNonNullType(implementedFieldType)) { - return isValidImplementationFieldType(fieldType.baseType(), implementedFieldType.baseType()); + return isValidImplementationFieldType(fieldType.ofType(), implementedFieldType.ofType()); + } else { + return isValidImplementationFieldType(fieldType.ofType(), implementedFieldType); } - return false; } if (isListType(fieldType) && isListType(implementedFieldType)) { - return isValidImplementationFieldType(fieldType.baseType(), implementedFieldType.baseType()); + return isValidImplementationFieldType(fieldType.ofType(), implementedFieldType.ofType()); } - return isSubtype(fieldType, implementedFieldType); + return !isWrapperType(fieldType) && + !isWrapperType(implementedFieldType) && + fieldType.name === implementedFieldType.name; } function selectionSetHasDirectives(selectionSet: SelectionSet): boolean { @@ -569,24 +585,17 @@ function validateFieldValue({ }): void { const expectedType = fromContextParent.type; assert(expectedType, 'Expected a type'); - const { - selectionType, - } = validateSelectionFormat({ context, selection, fromContextParent, errorCollector }); + const validateSelectionFormatResults = + validateSelectionFormat({ context, selection, fromContextParent, errorCollector }); + const selectionType = validateSelectionFormatResults.selectionType; // if there was an error, just return, we've already added it to the errorCollector if (selectionType === 'error') { return; } - - // reduce setContextLocations to an array of ObjectType and InterfaceType. If a UnionType is present, use .types() to expand it - const expandedTypes = setContextLocations.reduce((acc, location) => { - if (location.kind === 'UnionType') { - return acc.concat(location.types()); - } - return acc.concat(location); - }, [] as (ObjectType | InterfaceType)[]); - for (const location of expandedTypes) { + const usedTypeConditions = new Set; + for (const location of setContextLocations) { // for each location, we need to validate that the selection will result in exactly one field being selected // the number of selection sets created will be the same let selectionSet: SelectionSet; @@ -620,7 +629,7 @@ function validateFieldValue({ metadata, fromContextParent, }); - if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { + if (resolvedType === undefined || !isValidImplementationFieldType(resolvedType, expectedType!)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, { nodes: sourceASTs(fromContextParent) } @@ -629,49 +638,88 @@ function validateFieldValue({ } } else if (selectionType === 'inlineFragment') { // ensure that each location maps to exactly one fragment - const types = selectionSet.selections() - .filter((s): s is FragmentSelection => s.kind === 'FragmentSelection') - .filter(s => { - const { typeCondition } = s.element; - assert(typeCondition, 'Expected a type condition on FragmentSelection'); - if (typeCondition.kind === 'ObjectType') { - return location.name === typeCondition.name; - } else if (typeCondition.kind === 'InterfaceType') { - return location.kind === 'InterfaceType' ? location.name === typeCondition.name : typeCondition.isPossibleRuntimeType(location); - } else if (typeCondition.kind === 'UnionType') { - return location.name === typeCondition.name; - } else { - assertUnreachable(typeCondition); + const selections: FragmentSelection[] = []; + for (const selection of selectionSet.selections()) { + if (selection.kind !== 'FragmentSelection') { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: selection should only contain a single field or at least one inline fragment}"`, + { nodes: sourceASTs(fromContextParent) } + )); + continue; + } + + const { typeCondition } = selection.element; + if (!typeCondition) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: inline fragments must have type conditions}"`, + { nodes: sourceASTs(fromContextParent) } + )); + continue; + } + + if (typeCondition.kind === 'ObjectType') { + if (possibleRuntimeTypes(location).includes(typeCondition)) { + selections.push(selection); + usedTypeConditions.add(typeCondition.name); } - }); + } else { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type conditions must be an object type}"`, + { nodes: sourceASTs(fromContextParent) } + )); + } + } - if (types.length === 0) { + if (selections.length === 0) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no type condition matches the location "${location.coordinate}"`, { nodes: sourceASTs(fromContextParent) } )); return; - } else if (types.length > 1) { + } else { + for (const selection of selections) { + let { resolvedType } = validateFieldValueType({ + currentType: selection.element.typeCondition!, + selectionSet: selection.selectionSet, + errorCollector, + metadata, + fromContextParent, + }); + + if (resolvedType === undefined) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } + + // Because other subgraphs may define members of the location type, + // it's always possible that none of the type conditions map, so we + // must remove any surrounding non-null wrapper if present. + if (isNonNullType(resolvedType)) { + resolvedType = resolvedType.ofType(); + } + + if (!isValidImplementationFieldType(resolvedType!, expectedType!)) { + errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + { nodes: sourceASTs(fromContextParent) } + )); + return; + } + } + } + } + } + + if (validateSelectionFormatResults.selectionType === 'inlineFragment') { + for (const typeCondition of validateSelectionFormatResults.typeConditions) { + if (!usedTypeConditions.has(typeCondition)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple type conditions match the location "${location.coordinate}"`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type condition "${typeCondition}" is never used.`, { nodes: sourceASTs(fromContextParent) } )); - return; - } else { - const { resolvedType } = validateFieldValueType({ - currentType: location, - selectionSet: types[0].selectionSet, - errorCollector, - metadata, - fromContextParent, - }); - if (resolvedType === undefined || !isValidImplementationFieldType(expectedType!, resolvedType)) { - errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, - { nodes: sourceASTs(fromContextParent) } - )); - return; - } } } } @@ -1559,13 +1607,25 @@ export class FederationBlueprint extends SchemaBlueprint { const parent = application.parent as ArgumentDefinition>; // error if parent's parent is an interface - if (parent?.parent?.parent?.kind === 'InterfaceType') { + if (parent?.parent?.parent?.kind !== 'ObjectType') { errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( - `@fromContext argument cannot be used on a field that exists on an interface "${application.parent.coordinate}".`, + `@fromContext argument cannot be used on a field that exists on an abstract type "${application.parent.coordinate}".`, { nodes: sourceASTs(application) } )); continue; } + + // error if the parent's parent implements an interface containing the field + const objectType = parent.parent.parent; + for (const implementedInterfaceType of objectType.interfaces()) { + const implementedInterfaceField = implementedInterfaceType.field(parent.parent.name); + if (implementedInterfaceField) { + errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( + `@fromContext argument cannot be used on a field implementing an interface field "${implementedInterfaceField.coordinate}".`, + { nodes: sourceASTs(application) } + )); + } + } if (parent.defaultValue !== undefined) { errorCollector.push(ERRORS.CONTEXT_NOT_SET.err( @@ -1599,7 +1659,6 @@ export class FederationBlueprint extends SchemaBlueprint { // validate that there is at least one resolvable key on the type const keyDirective = metadata.keyDirective(); - const objectType = parent.parent.parent; const keyApplications = objectType.appliedDirectivesOf(keyDirective); if (!keyApplications.some(app => app.arguments().resolvable || app.arguments().resolvable === undefined)) { errorCollector.push(ERRORS.CONTEXT_NO_RESOLVABLE_KEY.err( From cc6d499fe246a27f456ab15bea85f4e123ff3f2e Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 15:48:18 -0700 Subject: [PATCH 70/82] Translate subgraph names to GraphQL enum values using existing mapping --- .../src/extractSubgraphsFromSupergraph.ts | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index ec7ae0b58..1b021fc6c 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -212,6 +212,17 @@ export function extractSubgraphsFromSupergraph(supergraph: Schema, validateExtra return subgraph; }; + const subgraphNameToGraphEnumValue = new Map(); + for (const [k, v] of graphEnumNameToSubgraphName.entries()) { + subgraphNameToGraphEnumValue.set(v, k); + } + + const getSubgraphEnumValue = (subgraphName: string): string => { + const enumValue = subgraphNameToGraphEnumValue.get(subgraphName); + assert(enumValue, () => `Invalid subgraph name ${subgraphName} found: does not match a subgraph defined in the @join__Graph enum`); + return enumValue; + } + const types = filteredTypes(supergraph, joinSpec, coreFeatures.coreDefinition); const args: ExtractArguments = { supergraph, @@ -219,6 +230,7 @@ export function extractSubgraphsFromSupergraph(supergraph: Schema, validateExtra joinSpec, filteredTypes: types, getSubgraph, + getSubgraphEnumValue, }; if (isFed1) { extractSubgraphsFromFed1Supergraph(args); @@ -281,6 +293,7 @@ type ExtractArguments = { joinSpec: JoinSpecDefinition, filteredTypes: NamedType[], getSubgraph: (application: Directive) => Subgraph | undefined, + getSubgraphEnumValue: (subgraphName: string) => string } type SubgraphTypeInfo = Map; @@ -297,12 +310,13 @@ type TypesInfo = { unionTypes: TypeInfo[], }; -function addAllEmptySubgraphTypes({ - supergraph, - joinSpec, - filteredTypes, - getSubgraph, -}: ExtractArguments): TypesInfo { +function addAllEmptySubgraphTypes(args: ExtractArguments): TypesInfo { + const { + supergraph, + joinSpec, + filteredTypes, + getSubgraph, + } = args; const typeDirective = joinSpec.typeDirective(supergraph); const objOrItfTypes: TypeInfo[] = []; @@ -316,16 +330,16 @@ function addAllEmptySubgraphTypes({ // (on top of it making sense code-wise since both type behave exactly the same for most of what we're doing here). case 'InterfaceType': case 'ObjectType': - objOrItfTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); + objOrItfTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), args) }); break; case 'InputObjectType': - inputObjTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); + inputObjTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), args) }); break; case 'EnumType': - enumTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); + enumTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), args) }); break; case 'UnionType': - unionTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), getSubgraph, supergraph) }); + unionTypes.push({ type, subgraphsInfo: addEmptyType(type, type.appliedDirectivesOf(typeDirective), args) }); break; case 'ScalarType': // Scalar are a bit special in that they don't have any sub-component, so we don't track them beyond adding them to the @@ -350,9 +364,9 @@ function addAllEmptySubgraphTypes({ function addEmptyType( type: T, typeApplications: Directive[], - getSubgraph: (application: Directive) => Subgraph | undefined, - supergraph: Schema, + args: ExtractArguments, ): SubgraphTypeInfo { + const { supergraph, getSubgraph, getSubgraphEnumValue } = args; // In fed2, we always mark all types with `@join__type` but making sure. assert(typeApplications.length > 0, `Missing @join__type on ${type}`) const subgraphsInfo: SubgraphTypeInfo = new Map(); @@ -403,7 +417,7 @@ function addEmptyType( const graph = match ? match[1] : undefined; const context = match ? match[2] : undefined; assert(graph, `Invalid context name ${name} found in ${application} on ${application.parent}: does not match the expected pattern`); - const subgraphInfo = subgraphsInfo.get(graph.toUpperCase()); + const subgraphInfo = subgraphsInfo.get(getSubgraphEnumValue(graph)); const contextDirective = subgraphInfo?.subgraph.metadata().contextDirective(); if (subgraphInfo && contextDirective && isFederationDirectiveDefinedInSchema(contextDirective)) { subgraphInfo.type.applyDirective(contextDirective, {name: context}); From 3ccd70ae01ec505d4b6dd81c155928ad1dab6e6a Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 17:24:07 -0700 Subject: [PATCH 71/82] Update QueryGraph to have schema, and update option generation to parse against supergraph schema --- query-graphs-js/src/graphPath.ts | 55 ++++++++++++++++--------------- query-graphs-js/src/querygraph.ts | 16 ++++++--- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 77788ada1..28208e8ea 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -35,7 +35,7 @@ import { isScalarType, isEnumType, isUnionType, - SelectionSetUpdates, + Selection, } from "@apollo/federation-internals"; import { OpPathTree, traversePathTree } from "./pathTree"; import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRootType, FEDERATED_GRAPH_ROOT_SOURCE } from "./querygraph"; @@ -1913,19 +1913,19 @@ function canSatisfyConditions { if (parentType) { - if (parentType.name === t) { + const parentInSupergraph = path.graph.schema.type(parentType.name)!; + if (parentInSupergraph.name === t) { return true; } - if (isObjectType(parentType)) { - if (parentType.interfaces().some(i => i.name === t)) { + if (isObjectType(parentInSupergraph)) { + if (parentInSupergraph.interfaces().some(i => i.name === t)) { return true; } } - const tInSupergraph = parentType.schema().type(t); + const tInSupergraph = parentInSupergraph.schema().type(t); if (tInSupergraph && isUnionType(tInSupergraph)) { return tInSupergraph.types().some(t => t.name === parentType.name); } @@ -1933,17 +1933,21 @@ function canSatisfyConditions 1) { - const fragmentSelection = selectionSet.selections().find(s => s.kind === 'FragmentSelection' && s.element.typeCondition?.name === parentType.name); - if (fragmentSelection) { - const ss = new SelectionSetUpdates(); - ss.add(fragmentSelection); - selectionSet = ss.toSelectionSet(parentType); + // We want to ignore type conditions that are impossible/don't intersect with the parent type + selectionSet = selectionSet.lazyMap((selection): Selection | undefined => { + if (selection.kind === 'FragmentSelection') { + if (selection.element.typeCondition && isObjectType(selection.element.typeCondition)) { + if (!possibleRuntimeTypes(parentInSupergraph).includes(selection.element.typeCondition)) { + return undefined; + } + } } - } + return selection; + }) const resolution = conditionResolver(e, context, excludedEdges, excludedConditions, selectionSet); assert(edge.transition.kind === 'FieldCollection', () => `Expected edge to be a FieldCollection edge, got ${edge.transition.kind}`); @@ -1952,7 +1956,7 @@ function canSatisfyConditions `Expected to find arg index for ${cxt.coordinate}`); - contextMap.set(cxt.context, { selectionSet, levelsInDataPath, levelsInQueryPath, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id, argType: cxt.argType }); + contextMap.set(cxt.namedParameter, { selectionSet, levelsInDataPath, levelsInQueryPath, inboundEdge: e, pathTree: resolution.pathTree, paramName: cxt.namedParameter, id, argType: cxt.argType }); someSelectionUnsatisfied = someSelectionUnsatisfied || !resolution.satisfied; if (resolution.cost === -1 || totalCost === -1) { totalCost = -1; @@ -1968,7 +1972,7 @@ function canSatisfyConditions !contextMap.has(c.context))) { + if (requiredContexts.some(c => !contextMap.has(c.namedParameter))) { // in this case there is a context that is unsatisfied. Return no path. debug.groupEnd('@fromContext requires a context that is not set in graph path'); return { ...unsatisfiedConditionsResolution, unsatisfiedConditionReason: UnsatisfiedConditionReason.NO_CONTEXT_SET }; @@ -1980,14 +1984,11 @@ function canSatisfyConditions e.transition.kind === 'KeyResolution'); - assert(keyEdge, () => `Expected to find a key edge from ${edge.head}`); - - debug.log('@fromContext conditions are satisfied, but validating post-require key.'); - const postRequireKeyCondition = getLocallySatisfiableKey(path.graph, edge.head); - if (!postRequireKeyCondition) { - debug.groupEnd('Post-require conditions cannot be satisfied'); + // to jump back to this object as a precondition for satisfying it. + debug.log('@fromContext conditions are satisfied, but validating post-context key.'); + const postContextKeyCondition = getLocallySatisfiableKey(path.graph, edge.head); + if (!postContextKeyCondition) { + debug.groupEnd('Post-context conditions cannot be satisfied'); return { ...unsatisfiedConditionsResolution, unsatisfiedConditionReason: UnsatisfiedConditionReason.NO_POST_REQUIRE_KEY }; } @@ -2030,7 +2031,7 @@ function canSatisfyConditions, readonly subgraphToArgIndices: Map>, + + readonly schema: Schema, ) { this.nonTrivialFollowupEdges = preComputeNonTrivialFollowupEdges(this); } @@ -690,7 +692,7 @@ function resolvableKeyApplications( function federateSubgraphs(supergraph: Schema, subgraphs: QueryGraph[]): QueryGraph { const [verticesCount, rootKinds, schemas] = federatedProperties(subgraphs); - const builder = new GraphBuilder(verticesCount); + const builder = new GraphBuilder(supergraph, verticesCount); rootKinds.forEach(k => builder.createRootVertex( k, new ObjectType(federatedGraphRootTypeName(k)), @@ -1124,11 +1126,13 @@ class GraphBuilder { private readonly sources: Map = new Map(); private subgraphToArgs: Map = new Map(); private subgraphToArgIndices: Map> = new Map(); + readonly schema: Schema; - constructor(verticesCount?: number) { + constructor(schema: Schema, verticesCount?: number) { this.vertices = verticesCount ? new Array(verticesCount) : []; this.outEdges = verticesCount ? new Array(verticesCount) : []; this.inEdges = verticesCount ? new Array(verticesCount) : []; + this.schema = schema; } verticesForType(typeName: string): Vertex[] { @@ -1301,7 +1305,9 @@ class GraphBuilder { this.rootVertices, this.sources, this.subgraphToArgs, - this.subgraphToArgIndices); + this.subgraphToArgIndices, + this.schema, + ); } setContextMaps(subgraphToArgs: Map, subgraphToArgIndices: Map>) { @@ -1320,11 +1326,11 @@ class GraphBuilderFromSchema extends GraphBuilder { constructor( private readonly name: string, - private readonly schema: Schema, + schema: Schema, private readonly supergraph?: { apiSchema: Schema, isFed1: boolean }, private readonly overrideLabelsByCoordinate?: Map, ) { - super(); + super(schema); this.isFederatedSubgraph = !!supergraph && isFederationSubgraphSchema(schema); } From ac6497321976b4a6602d08dc8a1ae35a944f5594 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 17:59:43 -0700 Subject: [PATCH 72/82] Fix bug in parameter to context comparison --- internals-js/src/utils.ts | 3 +++ query-graphs-js/src/conditionsValidation.ts | 2 +- query-graphs-js/src/pathTree.ts | 11 +++++++---- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/internals-js/src/utils.ts b/internals-js/src/utils.ts index 8e80779c7..5657b5e86 100644 --- a/internals-js/src/utils.ts +++ b/internals-js/src/utils.ts @@ -463,6 +463,9 @@ export function composeSets(s1: Set | null, s2: Set | null): Set | n } export function setsEqual(s1: Set | null, s2: Set | null): boolean { + if (s1 === s2) { + return true; + } if (!s1 && !s2) { return true; } diff --git a/query-graphs-js/src/conditionsValidation.ts b/query-graphs-js/src/conditionsValidation.ts index 85231e55f..406a5961c 100644 --- a/query-graphs-js/src/conditionsValidation.ts +++ b/query-graphs-js/src/conditionsValidation.ts @@ -84,7 +84,7 @@ export function simpleValidationConditionResolver({ excludedConditions: ExcludedConditions, extraConditions?: SelectionSet, ): ConditionResolution => { - const conditions = (edge.conditions ?? extraConditions)!; // TODO: ensure that only one is set + const conditions = (extraConditions ?? edge.conditions)!; // TODO: ensure that only one is set excludedConditions = addConditionExclusion(excludedConditions, conditions); const initialPath: OpGraphPath = GraphPath.create(queryGraph, edge.head); diff --git a/query-graphs-js/src/pathTree.ts b/query-graphs-js/src/pathTree.ts index 5fb7c1f06..1fb324970 100644 --- a/query-graphs-js/src/pathTree.ts +++ b/query-graphs-js/src/pathTree.ts @@ -27,7 +27,7 @@ type Child = { function findTriggerIdx( triggerEquality: (t1: TTrigger, t2: TTrigger) => boolean, - forIndex: [TTrigger, OpPathTree | null, TElements, Set | null, Map | null][] | [TTrigger, OpPathTree | null, TElements][], + forIndex: [TTrigger, OpPathTree | null, TElements, Set | null, Map | null][], trigger: TTrigger ): number { for (let i = 0; i < forIndex.length; i++) { @@ -276,6 +276,9 @@ export class PathTree | null, ptc2: Map | null): boolean { + if (ptc1 === ptc2) { + return true; + } const thisKeys = Array.from(ptc1?.keys() ?? []); const thatKeys = Array.from(ptc2?.keys() ?? []); @@ -290,13 +293,13 @@ export class PathTree Date: Mon, 13 May 2024 19:30:00 -0700 Subject: [PATCH 73/82] Updated computeGroupsForTree() for context handling --- query-planner-js/src/buildPlan.ts | 130 +++++++++++++----------------- 1 file changed, 56 insertions(+), 74 deletions(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 79936460a..c1cc6fd4c 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -1673,43 +1673,25 @@ type FieldToAlias = { alias: string, } -// function createPathFromSelection(selection: Selection): string[] { -// const path: string[] = []; - -// const helper = (sel: Selection) => { -// if (sel.kind === 'FieldSelection') { -// path.push(sel.element.name); -// } else if (sel.kind === 'FragmentSelection') { -// path.push(`... ${sel.element.typeCondition ? sel.element.typeCondition.name : ''}`); -// } else { -// assertUnreachable(sel); -// } -// const ss = sel.selectionSet; -// if (ss && ss.selections().length > 0) { -// helper(ss.selections()[0]); -// } -// }; - -// helper(selection); -// return path; -// } - -// function selectionAsKeyRenamer(selection: Selection, relPath: string[], alias: string): FetchDataKeyRenamer { -// return { -// kind: 'KeyRenamer', -// path: relPath.concat(createPathFromSelection(selection)), -// renameKeyTo: alias, -// } -// } - function selectionSetAsKeyRenamers(selectionSet: SelectionSet, relPath: string[], alias: string): FetchDataKeyRenamer[] { return selectionSet.selections().map((selection: Selection): FetchDataKeyRenamer[] | undefined => { if (selection.kind === 'FieldSelection') { - return [{ - kind: 'KeyRenamer', - path: [...relPath, selection.element.name], - renameKeyTo: alias, - }]; + // We always have at least one '..' in the relative path. + if (relPath[relPath.length - 1] === '..') { + const runtimeTypes = + possibleRuntimeTypes(selectionSet.parentType).map((t) => t.name).join(","); + return [{ + kind: 'KeyRenamer', + path: [...relPath, `... on ${runtimeTypes}`, selection.element.name], + renameKeyTo: alias, + }]; + } else { + return [{ + kind: 'KeyRenamer', + path: [...relPath, selection.element.name], + renameKeyTo: alias, + }]; + } } else if (selection.kind === 'FragmentSelection') { const element = selection.element; if (element.typeCondition) { @@ -4198,7 +4180,6 @@ function computeGroupsForTree( startGroup: group, initialGroupPath: path, initialDeferContext: deferContextForConditions(deferContext), - initialContextsToConditionsGroups: contextToConditionsGroups, }); updateCreatedGroups(createdGroups, ...conditionsGroups); // Then we can "take the edge", creating a new group. That group depends @@ -4378,7 +4359,7 @@ function computeGroupsForTree( updated.path = requireResult.path; if (contextToSelection) { - const newContextToConditionsGroups = new Map(); + const newContextToConditionsGroups = new Map([...contextToConditionsGroups]); for (const context of contextToSelection) { newContextToConditionsGroups.set(context, [group]); } @@ -4392,11 +4373,10 @@ function computeGroupsForTree( startGroup: group, initialGroupPath: path, initialDeferContext: deferContextForConditions(deferContext), - initialContextsToConditionsGroups: contextToConditionsGroups, }); if (contextToSelection) { - const newContextToConditionsGroups = new Map(); + const newContextToConditionsGroups = new Map([...contextToConditionsGroups]); for (const context of contextToSelection) { newContextToConditionsGroups.set(context, [group, ...conditionsGroups]); } @@ -4447,60 +4427,62 @@ function computeGroupsForTree( // if we're going to start using context variables, every variable used must be set in a different parent // fetch group or else we need to create a new one - if (parameterToContext && Array.from(parameterToContext.values()).some(({ contextId }) => updated.contextToConditionsGroups.get(contextId)?.[0] === group)) { + if (parameterToContext && Array.from(parameterToContext.values()).some(({ contextId }) => updated.contextToConditionsGroups.get(contextId)?.[0] === updated.group)) { + assert(group === updated.group, "Group created by @requires handling shouldn't have set context already"); + // All groups that get the contextual variable should be parents of this group + const conditionGroups: Set = new Set(); + for (const { contextId } of parameterToContext.values()) { + const groups = updated.contextToConditionsGroups.get(contextId); + assert(groups, () => `Could not find groups for context ${contextId}`); + for (const conditionGroup of groups) { + conditionGroups.add(conditionGroup); + } + } + assert(isCompositeType(edge.head.type), () => `Expected a composite type for ${edge.head.type}`); - const newGroup = dependencyGraph.getOrCreateKeyFetchGroup({ + updated.group = dependencyGraph.getOrCreateKeyFetchGroup({ subgraphName: edge.tail.source, - mergeAt: path.inResponse(), + mergeAt: updated.path.inResponse(), type: edge.head.type, - parent: { group, path: path.inGroup() }, - conditionsGroups: [], + parent: { group: group, path: path.inGroup() }, + conditionsGroups: [...conditionGroups], }); + updateCreatedGroups(createdGroups, updated.group); + updated.path = path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, edge.head.type, context)); const keyCondition = getLocallySatisfiableKey(dependencyGraph.federatedQueryGraph, edge.head); assert(keyCondition, () => `canSatisfyConditions() validation should have required a key to be present for ${edge}`); - const keyInputs = newCompositeTypeSelectionSet(edge.head.type).updates().add(keyCondition).toSelectionSet(edge.head.type); - group.addAtPath(path.inGroup(), keyInputs.selections()); + const keyInputs = newCompositeTypeSelectionSet(edge.head.type); + keyInputs.updates().add(keyCondition); + group.addAtPath(path.inGroup(), keyInputs.get()); + // We also ensure to get the __typename of the current type in the "original" group. + // TODO: It may be safe to remove this, but I'm not 100% convinced. Come back and take a look at some point + group.addAtPath(path.inGroup().concat(new Field(edge.head.type.typenameField()!))); + const inputType = dependencyGraph.typeForFetchInputs(edge.head.type.name); - const inputSelectionSet = newCompositeTypeSelectionSet(inputType).updates().add(keyCondition).toSelectionSet(inputType); - const inputs = wrapInputsSelections(inputType, inputSelectionSet, context); - newGroup.addInputs( + const inputSelectionSet = newCompositeTypeSelectionSet(inputType); + inputSelectionSet.updates().add(keyCondition); + const inputs = wrapInputsSelections(inputType, inputSelectionSet.get(), context); + updated.group.addInputs( inputs, computeInputRewritesOnKeyFetch(edge.head.type.name, edge.head.type), ); - - newGroup.addParent({ group, path: path.inGroup() }); - // all groups that get the contextual variable should be parents of this group - for (const { contextId } of parameterToContext.values()) { - const groups = updated.contextToConditionsGroups.get(contextId); - assert(groups, () => `Could not find groups for context ${contextId}`); - for (const parentGroup of groups) { - newGroup.addParent({ group: parentGroup, path: path.inGroup() }); - } + // Add the condition groups as parent groups. + for (const parentGroup of conditionGroups) { + updated.group.addParent({ group: parentGroup }); } + + // Add context renamers. for (const [_, { contextId, selectionSet, relativePath, subgraphArgType }] of parameterToContext) { - newGroup.addInputContext(contextId, subgraphArgType); + updated.group.addInputContext(contextId, subgraphArgType); const keyRenamers = selectionSetAsKeyRenamers(selectionSet, relativePath, contextId); for (const keyRenamer of keyRenamers) { - newGroup.addContextRenamer(keyRenamer); + updated.group.addContextRenamer(keyRenamer); } } - - // We also ensure to get the __typename of the current type in the "original" group. - // TODO: It may be safe to remove this, but I'm not 100% convinced. Come back and take a look at some point - group.addAtPath(path.inGroup().concat(new Field(edge.head.type.typenameField()!))); - updateCreatedGroups(createdGroups, newGroup); - - stack.push({ - tree, - group: newGroup, - path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, edge.head.type, context)), - context, - deferContext: updatedDeferContext, - contextToConditionsGroups, - }); + stack.push(updated); } else { // in this case we can just continue with the current group, but we need to add the context rewrites if (parameterToContext) { @@ -4508,7 +4490,7 @@ function computeGroupsForTree( updated.group.addInputContext(contextId, subgraphArgType); const keyRenamers = selectionSetAsKeyRenamers(selectionSet, relativePath, contextId); for (const keyRenamer of keyRenamers) { - group.addContextRenamer(keyRenamer); + updated.group.addContextRenamer(keyRenamer); } } } From 92bb1e85ac901892c3da17105763ddab2d140853 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 21:35:51 -0500 Subject: [PATCH 74/82] update tests to use ofType and remove nonNullable from contextual arguments --- .../src/__tests__/compose.setContext.test.ts | 115 +++++------------- internals-js/src/federation.ts | 14 +-- .../src/__tests__/buildPlan.test.ts | 12 +- 3 files changed, 46 insertions(+), 95 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 8a5d55c4a..37c699deb 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -19,7 +19,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -59,7 +59,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: [String]! @fromContext(field: "$context { prop }")): Int! + field(a: [String] @fromContext(field: "$context { prop }")): Int! } `, }; @@ -148,7 +148,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -195,7 +195,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -218,7 +218,7 @@ describe("setContext tests", () => { expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); expect(result.errors?.[0].message).toBe( - '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection does not match the expected type "String!"' + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection "Int" does not match the expected type "String"' ); }); @@ -247,7 +247,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! field( - a: String! + a: String @fromContext( field: "$context ... on Foo { prop } ... on Bar { prop2 }" ) @@ -291,7 +291,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$unknown { prop }")): Int! + field(a: String @fromContext(field: "$unknown { prop }")): Int! } `, }; @@ -336,7 +336,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! field( - a: String! @fromContext(field: "$context { invalidprop }") + a: String @fromContext(field: "$context { invalidprop }") ): Int! } `, @@ -381,7 +381,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "{ prop }")): Int! + field(a: String @fromContext(field: "{ prop }")): Int! } `, }; @@ -432,7 +432,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! field( - a: String! @fromContext(field: "$context ... on Foo { prop }") + a: String @fromContext(field: "$context ... on Foo { prop }") ): Int! } `, @@ -481,7 +481,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -526,7 +526,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! field( - a: String! @fromContext(field: "$context ... on I { prop }") + a: String @fromContext(field: "$context ... on I { prop }") ): Int! } `, @@ -572,7 +572,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! field( - a: String! + a: String @fromContext( field: "$context ... on I { prop } ... on T { prop }" ) @@ -626,7 +626,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -675,7 +675,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -698,7 +698,7 @@ describe("setContext tests", () => { expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); expect(result.errors?.[0].message).toBe( - '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T2. Error: Cannot query field "prop" on type "T2".' + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid for type T. Error: Cannot query field "prop" on type "T".' ); }); it.todo("type mismatch in context variable"); @@ -759,7 +759,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -781,7 +781,7 @@ describe("setContext tests", () => { expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); expect(result.errors?.[0].message).toBe( - '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection does not match the expected type "String!"' + '[Subgraph1] Context "context" is used in "U.field(a:)" but the selection is invalid: the type of the selection does not match the expected type "String"' ); }); @@ -802,7 +802,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { id prop }")): Int! + field(a: String @fromContext(field: "$context { id prop }")): Int! } `, }; @@ -845,7 +845,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -892,7 +892,7 @@ describe("setContext tests", () => { type U @key(fields: "id", resolvable: false) { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -936,7 +936,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { foo: prop }")): Int! + field(a: String @fromContext(field: "$context { foo: prop }")): Int! } `, }; @@ -980,7 +980,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$_context { prop }")): Int! + field(a: String @fromContext(field: "$_context { prop }")): Int! } `, }; @@ -1025,7 +1025,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop @foo }")): Int! + field(a: String @fromContext(field: "$context { prop @foo }")): Int! } `, }; @@ -1070,7 +1070,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -1114,7 +1114,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! @shareable } `, @@ -1156,7 +1156,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! @shareable } `, @@ -1202,7 +1202,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! @shareable } `, @@ -1244,7 +1244,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! @shareable } `, @@ -1293,7 +1293,7 @@ describe("setContext tests", () => { utl: "https://Subgraph1", typeDefs: gql` directive @foo( - a: String! @fromContext(field: "$context { prop }") + a: String @fromContext(field: "$context { prop }") ) on FIELD_DEFINITION type Query { @@ -1353,7 +1353,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! field( - a: String! = "default" @fromContext(field: "$context { prop }") + a: String = "default" @fromContext(field: "$context { prop }") ): Int! } `, @@ -1381,55 +1381,6 @@ describe("setContext tests", () => { ); }); - it("forbid contextual arguments on interfaces", () => { - const subgraph1 = { - name: "Subgraph1", - utl: "https://Subgraph1", - typeDefs: gql` - type Query { - t: T! - } - - interface I @key(fields: "id") { - id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! - } - - type T @key(fields: "id") @context(name: "context") { - id: ID! - u: U! - prop: String! - } - - type U implements I @key(fields: "id") { - id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! - } - `, - }; - - const subgraph2 = { - name: "Subgraph2", - utl: "https://Subgraph2", - typeDefs: gql` - type Query { - a: Int! - } - - type U @key(fields: "id") { - id: ID! - } - `, - }; - - const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); - expect(result.schema).toBeUndefined(); - expect(result.errors?.length).toBe(1); - expect(result.errors?.[0].message).toBe( - '[Subgraph1] @fromContext argument cannot be used on a field that exists on an interface "I.field(a:)".' - ); - }); - it("forbid contextual arguments on interfaces", () => { const subgraph1 = { name: "Subgraph1", @@ -1452,7 +1403,7 @@ describe("setContext tests", () => { type U implements I @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -1475,7 +1426,7 @@ describe("setContext tests", () => { expect(result.schema).toBeUndefined(); expect(result.errors?.length).toBe(1); expect(result.errors?.[0].message).toBe( - "[Subgraph1] Field U.field includes required argument a that is missing from the Interface field I.field." + '[Subgraph1] @fromContext argument cannot be used on a field implementing an interface field "I.field".' ); }); }); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 6e8329fb0..3a1ba1f84 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -370,7 +370,7 @@ const wrapResolvedType = ({ let unwrappedType: NamedType | WrapperType = originalType; while(unwrappedType.kind === 'NonNullType' || unwrappedType.kind === 'ListType') { stack.push(unwrappedType.kind); - unwrappedType = unwrappedType.baseType(); + unwrappedType = unwrappedType.ofType; } let type: NamedType | WrapperType = resolvedType; @@ -533,13 +533,13 @@ const validateSelectionFormat = ({ function isValidImplementationFieldType(fieldType: InputType, implementedFieldType: InputType): boolean { if (isNonNullType(fieldType)) { if (isNonNullType(implementedFieldType)) { - return isValidImplementationFieldType(fieldType.ofType(), implementedFieldType.ofType()); + return isValidImplementationFieldType(fieldType.ofType, implementedFieldType.ofType); } else { - return isValidImplementationFieldType(fieldType.ofType(), implementedFieldType); + return isValidImplementationFieldType(fieldType.ofType, implementedFieldType); } } if (isListType(fieldType) && isListType(implementedFieldType)) { - return isValidImplementationFieldType(fieldType.ofType(), implementedFieldType.ofType()); + return isValidImplementationFieldType(fieldType.ofType, implementedFieldType.ofType); } return !isWrapperType(fieldType) && !isWrapperType(implementedFieldType) && @@ -631,7 +631,7 @@ function validateFieldValue({ }); if (resolvedType === undefined || !isValidImplementationFieldType(resolvedType, expectedType!)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection "${resolvedType}" does not match the expected type "${expectedType?.toString()}"`, { nodes: sourceASTs(fromContextParent) } )); return; @@ -698,12 +698,12 @@ function validateFieldValue({ // it's always possible that none of the type conditions map, so we // must remove any surrounding non-null wrapper if present. if (isNonNullType(resolvedType)) { - resolvedType = resolvedType.ofType(); + resolvedType = resolvedType.ofType; } if (!isValidImplementationFieldType(resolvedType!, expectedType!)) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection "${resolvedType?.toString()}" does not match the expected type "${expectedType?.toString()}"`, { nodes: sourceASTs(fromContextParent) } )); return; diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 32bf6cc75..abff18d90 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8841,7 +8841,7 @@ describe('@fromContext impacts on query planning', () => { type U @key(fields: "id") { id: ID! b: String! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -8951,7 +8951,7 @@ describe('@fromContext impacts on query planning', () => { } type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -9100,7 +9100,7 @@ describe('@fromContext impacts on query planning', () => { type U @key(fields: "id") { id: ID! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -9266,7 +9266,7 @@ describe('@fromContext impacts on query planning', () => { type U @key(fields: "id") { id: ID! b: String! - field(a: String! @fromContext(field: "$context { prop }")): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, }; @@ -9392,7 +9392,7 @@ describe('@fromContext impacts on query planning', () => { id: ID! b: String! field( - a: String! @fromContext(field: "$context ... on I { prop }") + a: String @fromContext(field: "$context ... on I { prop }") ): Int! } `, @@ -9517,7 +9517,7 @@ describe('@fromContext impacts on query planning', () => { id: ID! b: String! field( - a: String! + a: String @fromContext( field: "$context ... on A { prop } ... on B { prop }" ) From 1c0ab3d77e733ca3d1a3c4ea24f045b991ee9826 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 19:40:34 -0700 Subject: [PATCH 75/82] Remove unused field in query plan --- query-planner-js/src/QueryPlan.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/query-planner-js/src/QueryPlan.ts b/query-planner-js/src/QueryPlan.ts index 218b52f77..5a5850816 100644 --- a/query-planner-js/src/QueryPlan.ts +++ b/query-planner-js/src/QueryPlan.ts @@ -40,7 +40,6 @@ export interface FetchNode { // If QP defer support is enabled _and_ the `serviceName` subgraph support defer, then whether `operation` contains some @defer. Unset otherwise. hasDefers?: boolean, variableUsages?: string[]; - contextVariableUsages?: Map; requires?: QueryPlanSelectionNode[]; operation: string; operationName: string | undefined; From 0f09e7ebe0f9e736453b3b7e11b319860070f077 Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 19:58:44 -0700 Subject: [PATCH 76/82] Fix bug where context additions happened at wrong location in computeGroupsForTree() --- query-planner-js/src/buildPlan.ts | 84 +++++++++++++++---------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index c1cc6fd4c..37215ff49 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -4385,46 +4385,6 @@ function computeGroupsForTree( updateCreatedGroups(createdGroups, ...conditionsGroups); } - if (updatedOperation.kind === 'Field' && updatedOperation.name === typenameFieldName) { - // Because of the optimization done in `QueryPlanner.optimizeSiblingTypenames`, we will rarely get an explicit `__typename` - // edge here. But one case where it can happen is where an @interfaceObject was involved, and we had to force jumping to - // another subgraph for getting the "true" `__typename`. However, this case can sometimes lead to fetch group that only - // exists for that `__typename` resolution and that "look" useless. That, we could have a fetch group that looks like: - // Fetch(service: "Subgraph2") { - // { - // ... on I { - // __typename - // id - // } - // } => - // { - // ... on I { - // __typename - // } - // } - // } - // but the trick is that the `__typename` in the input will be the name of the interface itself (`I` in this case) - // but the one return after the fetch will the name of the actual implementation (some implementation of `I`). - // *But* we later have optimizations that would remove such a group, on the group that the output is included - // in the input, which is in general the right thing to do (and genuinely ensure that some useless groups created when - // handling complex @require gets eliminated). So we "protect" the group in this case to ensure that later - // optimization doesn't kick in in this case. - updated.group.mustPreserveSelection = true - } - - if (edge.transition.kind === 'InterfaceObjectFakeDownCast') { - // We shouldn't add the operation "as is" as it's a down-cast but we're "faking it". However, - // if the operation has directives, we should preserve that. - assert(updatedOperation.kind === 'FragmentElement', () => `Unexpected operation ${updatedOperation} for edge ${edge}`); - if (updatedOperation.appliedDirectives.length > 0) { - // We want to keep the directives, but we clear the condition since it's to a type that doesn't exists in the - // subgraph we're currently in. - updated.path = updated.path.add(updatedOperation.withUpdatedCondition(undefined)); - } - } else { - updated.path = updated.path.add(updatedOperation); - } - // if we're going to start using context variables, every variable used must be set in a different parent // fetch group or else we need to create a new one if (parameterToContext && Array.from(parameterToContext.values()).some(({ contextId }) => updated.contextToConditionsGroups.get(contextId)?.[0] === updated.group)) { @@ -4482,7 +4442,6 @@ function computeGroupsForTree( updated.group.addContextRenamer(keyRenamer); } } - stack.push(updated); } else { // in this case we can just continue with the current group, but we need to add the context rewrites if (parameterToContext) { @@ -4494,8 +4453,49 @@ function computeGroupsForTree( } } } - stack.push(updated); } + + if (updatedOperation.kind === 'Field' && updatedOperation.name === typenameFieldName) { + // Because of the optimization done in `QueryPlanner.optimizeSiblingTypenames`, we will rarely get an explicit `__typename` + // edge here. But one case where it can happen is where an @interfaceObject was involved, and we had to force jumping to + // another subgraph for getting the "true" `__typename`. However, this case can sometimes lead to fetch group that only + // exists for that `__typename` resolution and that "look" useless. That, we could have a fetch group that looks like: + // Fetch(service: "Subgraph2") { + // { + // ... on I { + // __typename + // id + // } + // } => + // { + // ... on I { + // __typename + // } + // } + // } + // but the trick is that the `__typename` in the input will be the name of the interface itself (`I` in this case) + // but the one return after the fetch will the name of the actual implementation (some implementation of `I`). + // *But* we later have optimizations that would remove such a group, on the group that the output is included + // in the input, which is in general the right thing to do (and genuinely ensure that some useless groups created when + // handling complex @require gets eliminated). So we "protect" the group in this case to ensure that later + // optimization doesn't kick in in this case. + updated.group.mustPreserveSelection = true + } + + if (edge.transition.kind === 'InterfaceObjectFakeDownCast') { + // We shouldn't add the operation "as is" as it's a down-cast but we're "faking it". However, + // if the operation has directives, we should preserve that. + assert(updatedOperation.kind === 'FragmentElement', () => `Unexpected operation ${updatedOperation} for edge ${edge}`); + if (updatedOperation.appliedDirectives.length > 0) { + // We want to keep the directives, but we clear the condition since it's to a type that doesn't exists in the + // subgraph we're currently in. + updated.path = updated.path.add(updatedOperation.withUpdatedCondition(undefined)); + } + } else { + updated.path = updated.path.add(updatedOperation); + } + + stack.push(updated); } } } From 2aa8f7607c4fd499c640f2c379adf5cda1b3557c Mon Sep 17 00:00:00 2001 From: "Sachin D. Shinde" Date: Mon, 13 May 2024 20:16:53 -0700 Subject: [PATCH 77/82] Update composition validation to handle bad subgraph names --- composition-js/src/compose.ts | 7 ++++- composition-js/src/validate.ts | 27 ++++++++++++++----- .../src/extractSubgraphsFromSupergraph.ts | 4 +-- internals-js/src/supergraphs.ts | 14 +++++++++- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/composition-js/src/compose.ts b/composition-js/src/compose.ts index 9408d8b6b..4c75e701e 100644 --- a/composition-js/src/compose.ts +++ b/composition-js/src/compose.ts @@ -70,7 +70,12 @@ export function compose(subgraphs: Subgraphs, options: CompositionOptions = {}): const supergraph = new Supergraph(mergeResult.supergraph, null); const supergraphQueryGraph = buildSupergraphAPIQueryGraph(supergraph); const federatedQueryGraph = buildFederatedQueryGraph(supergraph, false); - const { errors, hints } = validateGraphComposition(supergraph.schema, supergraphQueryGraph, federatedQueryGraph); + const { errors, hints } = validateGraphComposition( + supergraph.schema, + supergraph.subgraphNameToGraphEnumValue(), + supergraphQueryGraph, + federatedQueryGraph + ); if (errors) { return { errors }; } diff --git a/composition-js/src/validate.ts b/composition-js/src/validate.ts index 534cdebd5..4a62a4158 100644 --- a/composition-js/src/validate.ts +++ b/composition-js/src/validate.ts @@ -306,13 +306,19 @@ function generateWitnessValue(type: InputType): any { */ export function validateGraphComposition( supergraphSchema: Schema, + subgraphNameToGraphEnumValue: Map, supergraphAPI: QueryGraph, federatedQueryGraph: QueryGraph, ): { errors? : GraphQLError[], hints? : CompositionHint[], } { - const { errors, hints } = new ValidationTraversal(supergraphSchema, supergraphAPI, federatedQueryGraph).validate(); + const { errors, hints } = new ValidationTraversal( + supergraphSchema, + subgraphNameToGraphEnumValue, + supergraphAPI, + federatedQueryGraph, + ).validate(); return errors.length > 0 ? { errors, hints } : { hints }; } @@ -346,6 +352,7 @@ export class ValidationContext { constructor( readonly supergraphSchema: Schema, + readonly subgraphNameToGraphEnumValue: Map, ) { const [_, joinSpec] = validateSupergraph(supergraphSchema); this.joinTypeDirective = joinSpec.typeDirective(supergraphSchema); @@ -617,18 +624,20 @@ export class ValidationState { return subgraphs; } - currentSubgraphContextKeys(): Set { + currentSubgraphContextKeys(subgraphNameToGraphEnumValue: Map): Set { const subgraphContextKeys: Set = new Set(); for (const pathInfo of this.subgraphPathInfos) { - const subgraphName = pathInfo.path.path.tail.source; + const tailSubgraphName = pathInfo.path.path.tail.source; + const tailSubgraphEnumValue = subgraphNameToGraphEnumValue.get(tailSubgraphName); const entryKeys = []; const contexts = Array.from(pathInfo.contexts.entries()); contexts.sort((a, b) => a[0].localeCompare(b[0])); for (const [context, { subgraphName, typeName }] of contexts) { - entryKeys.push(`${context}=${subgraphName}.${typeName}`); + const subgraphEnumValue = subgraphNameToGraphEnumValue.get(subgraphName); + entryKeys.push(`${context}=${subgraphEnumValue}.${typeName}`); } subgraphContextKeys.add( - `${subgraphName}[${entryKeys.join(',')}]` + `${tailSubgraphEnumValue}[${entryKeys.join(',')}]` ); } return subgraphContextKeys; @@ -680,6 +689,7 @@ class ValidationTraversal { constructor( supergraphSchema: Schema, + subgraphNameToGraphEnumValue: Map, supergraphAPI: QueryGraph, federatedQueryGraph: QueryGraph, ) { @@ -696,7 +706,10 @@ class ValidationTraversal { overrideConditions: new Map(), }))); this.previousVisits = new QueryGraphState(supergraphAPI); - this.context = new ValidationContext(supergraphSchema); + this.context = new ValidationContext( + supergraphSchema, + subgraphNameToGraphEnumValue, + ); } validate(): { @@ -714,7 +727,7 @@ class ValidationTraversal { const vertex = state.supergraphPath.tail; const currentVertexVisit: VertexVisit = { - subgraphContextKeys: state.currentSubgraphContextKeys(), + subgraphContextKeys: state.currentSubgraphContextKeys(this.context.subgraphNameToGraphEnumValue), overrideConditions: state.selectedOverrideConditions }; const previousVisitsForVertex = this.previousVisits.getVertexState(vertex); diff --git a/internals-js/src/extractSubgraphsFromSupergraph.ts b/internals-js/src/extractSubgraphsFromSupergraph.ts index 1b021fc6c..6ee15899a 100644 --- a/internals-js/src/extractSubgraphsFromSupergraph.ts +++ b/internals-js/src/extractSubgraphsFromSupergraph.ts @@ -193,7 +193,7 @@ function typesUsedInFederationDirective(fieldSet: string | undefined, parentType return usedTypes; } -export function extractSubgraphsFromSupergraph(supergraph: Schema, validateExtractedSubgraphs: boolean = true): Subgraphs { +export function extractSubgraphsFromSupergraph(supergraph: Schema, validateExtractedSubgraphs: boolean = true): [Subgraphs, Map] { const [coreFeatures, joinSpec] = validateSupergraph(supergraph); const isFed1 = joinSpec.version.equals(new FeatureVersion(0, 1)); try { @@ -253,7 +253,7 @@ export function extractSubgraphsFromSupergraph(supergraph: Schema, validateExtra } } - return subgraphs; + return [subgraphs, subgraphNameToGraphEnumValue]; } catch (e) { let error = e; let subgraph: Subgraph | undefined = undefined; diff --git a/internals-js/src/supergraphs.ts b/internals-js/src/supergraphs.ts index c26faa9d5..65fe253cd 100644 --- a/internals-js/src/supergraphs.ts +++ b/internals-js/src/supergraphs.ts @@ -105,6 +105,7 @@ export class Supergraph { private readonly containedSubgraphs: readonly {name: string, url: string}[]; // Lazily computed as that requires a bit of work. private _subgraphs?: Subgraphs; + private _subgraphNameToGraphEnumValue?: Map; constructor( readonly schema: Schema, @@ -153,11 +154,22 @@ export class Supergraph { // Note that `extractSubgraphsFromSupergraph` redo a little bit of work we're already one, like validating // the supergraph. We could refactor things to avoid it, but it's completely negligible in practice so we // can leave that to "some day, maybe". - this._subgraphs = extractSubgraphsFromSupergraph(this.schema, this.shouldValidate); + const extractionResults = extractSubgraphsFromSupergraph(this.schema, this.shouldValidate); + this._subgraphs = extractionResults[0]; + this._subgraphNameToGraphEnumValue = extractionResults[1]; } return this._subgraphs; } + subgraphNameToGraphEnumValue(): Map { + if (!this._subgraphNameToGraphEnumValue) { + const extractionResults = extractSubgraphsFromSupergraph(this.schema, this.shouldValidate); + this._subgraphs = extractionResults[0]; + this._subgraphNameToGraphEnumValue = extractionResults[1]; + } + return new Map([...this._subgraphNameToGraphEnumValue]); + } + apiSchema(): Schema { return this.schema.toAPISchema(); } From c420486dd430f2ec9f6aacf97b0ae8dee37a337f Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 22:27:28 -0500 Subject: [PATCH 78/82] fixing up broken tests --- .../src/__tests__/compose.setContext.test.ts | 106 +----------------- .../extractSubgraphsFromSupergraph.test.ts | 4 +- internals-js/src/federation.ts | 4 +- .../src/__tests__/buildPlan.test.ts | 17 +-- 4 files changed, 14 insertions(+), 117 deletions(-) diff --git a/composition-js/src/__tests__/compose.setContext.test.ts b/composition-js/src/__tests__/compose.setContext.test.ts index 37c699deb..c5c5820f6 100644 --- a/composition-js/src/__tests__/compose.setContext.test.ts +++ b/composition-js/src/__tests__/compose.setContext.test.ts @@ -526,7 +526,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! field( - a: String @fromContext(field: "$context ... on I { prop }") + a: String @fromContext(field: "$context ... on T { prop }") ): Int! } `, @@ -550,105 +550,6 @@ describe("setContext tests", () => { assertCompositionSuccess(result); }); - it("type matches multiple type conditions", () => { - const subgraph1 = { - name: "Subgraph1", - utl: "https://Subgraph1", - typeDefs: gql` - type Query { - i: I! - } - - interface I @context(name: "context") { - prop: String! - } - - type T implements I @key(fields: "id") { - id: ID! - u: U! - prop: String! - } - - type U @key(fields: "id") { - id: ID! - field( - a: String - @fromContext( - field: "$context ... on I { prop } ... on T { prop }" - ) - ): Int! - } - `, - }; - - const subgraph2 = { - name: "Subgraph2", - utl: "https://Subgraph2", - typeDefs: gql` - type Query { - a: Int! - } - - type U @key(fields: "id") { - id: ID! - } - `, - }; - - const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); - assertCompositionSuccess(result); - }); - - it("@context works on union when all types have the designated property", () => { - const subgraph1 = { - name: "Subgraph1", - utl: "https://Subgraph1", - typeDefs: gql` - type Query { - t: T! - } - - union T @context(name: "context") = T1 | T2 - - type T1 @key(fields: "id") @context(name: "context") { - id: ID! - u: U! - prop: String! - a: String! - } - - type T2 @key(fields: "id") @context(name: "context") { - id: ID! - u: U! - prop: String! - b: String! - } - - type U @key(fields: "id") { - id: ID! - field(a: String @fromContext(field: "$context { prop }")): Int! - } - `, - }; - - const subgraph2 = { - name: "Subgraph2", - utl: "https://Subgraph2", - typeDefs: gql` - type Query { - a: Int! - } - - type U @key(fields: "id") { - id: ID! - } - `, - }; - - const result = composeAsFed2Subgraphs([subgraph1, subgraph2]); - assertCompositionSuccess(result); - }); - it("@context fails on union when type is missing prop", () => { const subgraph1 = { name: "Subgraph1", @@ -742,7 +643,8 @@ describe("setContext tests", () => { assertCompositionSuccess(result); }); - it("nullability mismatch is not ok if argument is non-nullable", () => { + // test is no longer valid since we don't allow non-nullable arguments + it.skip("nullability mismatch is not ok if argument is non-nullable", () => { const subgraph1 = { name: "Subgraph1", utl: "https://Subgraph1", @@ -759,7 +661,7 @@ describe("setContext tests", () => { type U @key(fields: "id") { id: ID! - field(a: String @fromContext(field: "$context { prop }")): Int! + field(a: String! @fromContext(field: "$context { prop }")): Int! } `, }; diff --git a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts index fe62e080c..6685a7974 100644 --- a/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts +++ b/internals-js/src/__tests__/extractSubgraphsFromSupergraph.test.ts @@ -902,7 +902,7 @@ type U @join__type(graph: SUBGRAPH2, key: "id") { id: ID! - field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String!", selection: "{ prop }"}]) + field: Int! @join__field(graph: SUBGRAPH1, contextArguments: [{context: "Subgraph1__context", name: "a", type: "String", selection: "{ prop }"}]) } `; @@ -924,6 +924,6 @@ type U @key(fields: "id") { id: ID! @shareable - field(a: String! @federation__fromContext(field: "$context { prop }")): Int! + field(a: String @federation__fromContext(field: "$context { prop }")): Int! }`); }); diff --git a/internals-js/src/federation.ts b/internals-js/src/federation.ts index 3a1ba1f84..bc36a73a0 100644 --- a/internals-js/src/federation.ts +++ b/internals-js/src/federation.ts @@ -651,7 +651,7 @@ function validateFieldValue({ const { typeCondition } = selection.element; if (!typeCondition) { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: inline fragments must have type conditions}"`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: inline fragments must have type conditions"`, { nodes: sourceASTs(fromContextParent) } )); continue; @@ -664,7 +664,7 @@ function validateFieldValue({ } } else { errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err( - `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type conditions must be an object type}"`, + `Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type conditions must be an object type"`, { nodes: sourceASTs(fromContextParent) } )); } diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index abff18d90..1c1e2e34f 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -8918,7 +8918,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - b field(a: $contextualArgument_1_0) } } @@ -8930,7 +8929,7 @@ describe('@fromContext impacts on query planning', () => { expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ { kind: 'KeyRenamer', - path: ['..', 'prop'], + path: ['..', '... on T', 'prop'], renameKeyTo: 'contextualArgument_1_0', }, ]); @@ -9048,7 +9047,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - id field(a: $contextualArgument_1_0) } } @@ -9060,7 +9058,7 @@ describe('@fromContext impacts on query planning', () => { expect((plan as any).node.nodes[2].node.contextRewrites).toEqual([ { kind: 'KeyRenamer', - path: ['..', 'prop'], + path: ['..', '... on T', 'prop'], renameKeyTo: 'contextualArgument_1_0', }, ]); @@ -9175,7 +9173,7 @@ describe('@fromContext impacts on query planning', () => { expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ { kind: 'KeyRenamer', - path: ['..', 'prop'], + path: ['..', '... on T', 'prop'], renameKeyTo: 'contextualArgument_1_0', }, ]); @@ -9343,7 +9341,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - b field(a: $contextualArgument_1_0) } } @@ -9355,7 +9352,7 @@ describe('@fromContext impacts on query planning', () => { expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ { kind: 'KeyRenamer', - path: ['..', 'prop'], + path: ['..', '... on T', 'prop'], renameKeyTo: 'contextualArgument_1_0', }, ]); @@ -9392,7 +9389,7 @@ describe('@fromContext impacts on query planning', () => { id: ID! b: String! field( - a: String @fromContext(field: "$context ... on I { prop }") + a: String @fromContext(field: "$context { prop }") ): Int! } `, @@ -9472,7 +9469,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - b field(a: $contextualArgument_1_0) } } @@ -9484,7 +9480,7 @@ describe('@fromContext impacts on query planning', () => { expect((plan as any).node.nodes[1].node.contextRewrites).toEqual([ { kind: 'KeyRenamer', - path: ['..', '... on I', 'prop'], + path: ['..', '... on A,B', 'prop'], renameKeyTo: 'contextualArgument_1_0', }, ]); @@ -9618,7 +9614,6 @@ describe('@fromContext impacts on query planning', () => { } => { ... on U { - b field(a: $contextualArgument_1_0) } } From f0f0ce7affe2287ce3bf8bbc90ac980252df7c1e Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 22:28:38 -0500 Subject: [PATCH 79/82] fix spelling --- query-graphs-js/src/graphPath.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/query-graphs-js/src/graphPath.ts b/query-graphs-js/src/graphPath.ts index 28208e8ea..2f10a6ffc 100644 --- a/query-graphs-js/src/graphPath.ts +++ b/query-graphs-js/src/graphPath.ts @@ -1732,8 +1732,8 @@ function advancePathWithDirectTransition( : conditionResolution.unsatisfiedConditionReason === UnsatisfiedConditionReason.NO_CONTEXT_SET ? `could not find a match for required context for field "${field.coordinate}"` // TODO: This isn't necessarily just because an @requires - // condition was unsatisified, but could also be because a - // @fromContext condition was unsatisified. + // condition was unsatisfied, but could also be because a + // @fromContext condition was unsatisfied. : `cannot satisfy @require conditions on field "${field.coordinate}"${warnOnKeyFieldsMarkedExternal(parentTypeInSubgraph)}`; deadEnds.push({ sourceSubgraph: edge.head.source, From cf64c4a8bf6c82ff86c1eb694f3dfdf2fb5c3cc7 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 22:47:38 -0500 Subject: [PATCH 80/82] add changeset --- .changeset/hungry-llamas-remember.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/hungry-llamas-remember.md diff --git a/.changeset/hungry-llamas-remember.md b/.changeset/hungry-llamas-remember.md new file mode 100644 index 000000000..b2898bb7a --- /dev/null +++ b/.changeset/hungry-llamas-remember.md @@ -0,0 +1,25 @@ +--- +"@apollo/query-planner": minor +"@apollo/query-graphs": minor +"@apollo/composition": minor +"@apollo/federation-internals": minor +"@apollo/gateway": minor +--- + +Implement new directives to allow getting and setting context. This allows resolvers to reference and access data referenced by entities that exist in the GraphPath that was used to access the field. The following example demonstrates the abillity to access the `prop` field within the Child resolver. + +```graphql +type Query { + p: Parent! +} +type Parent @key(fields: "id") @context(name: "context") { + id: ID! + child: Child! + prop: String! +} +type Child @key(fields: "id") { + id: ID! + b: String! + field(a: String @fromContext(field: "$context { prop }")): Int! +} +``` From d6f99b505ee8d6cb90716b2ab69385d707ebe3fd Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 22:54:15 -0500 Subject: [PATCH 81/82] spelling --- .changeset/hungry-llamas-remember.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/hungry-llamas-remember.md b/.changeset/hungry-llamas-remember.md index b2898bb7a..e295a96db 100644 --- a/.changeset/hungry-llamas-remember.md +++ b/.changeset/hungry-llamas-remember.md @@ -6,7 +6,7 @@ "@apollo/gateway": minor --- -Implement new directives to allow getting and setting context. This allows resolvers to reference and access data referenced by entities that exist in the GraphPath that was used to access the field. The following example demonstrates the abillity to access the `prop` field within the Child resolver. +Implement new directives to allow getting and setting context. This allows resolvers to reference and access data referenced by entities that exist in the GraphPath that was used to access the field. The following example demonstrates the ability to access the `prop` field within the Child resolver. ```graphql type Query { From dd72c8ec319895abd127b656d5bc8a45fd45b2a3 Mon Sep 17 00:00:00 2001 From: Chris Lenfest Date: Mon, 13 May 2024 22:55:57 -0500 Subject: [PATCH 82/82] prettier fix --- query-planner-js/src/__tests__/buildPlan.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index 1c1e2e34f..a0b94583b 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -9388,9 +9388,7 @@ describe('@fromContext impacts on query planning', () => { type U @key(fields: "id") { id: ID! b: String! - field( - a: String @fromContext(field: "$context { prop }") - ): Int! + field(a: String @fromContext(field: "$context { prop }")): Int! } `, };