diff --git a/composition/src/ast/utils.ts b/composition/src/ast/utils.ts index 563c2a1a3f..c56888c87b 100644 --- a/composition/src/ast/utils.ts +++ b/composition/src/ast/utils.ts @@ -48,7 +48,14 @@ import { SCHEMA_UPPER, SUBSCRIPTION, UNION_UPPER, + VALID_PROVIDES_PARENT_KINDS, } from '../utils/string-constants'; +import { + CompositeOutputData, + type ParentDefinitionData, + UnionDefinitionData, + type ValidProvidesParentData, +} from '../schema-building/types/types'; export function isObjectLikeNodeEntity(node: CompositeOutputNode): boolean { if (!node.directives?.length) { @@ -301,3 +308,7 @@ export type ParentTypeNode = export type InterfaceNodeKind = Kind.INTERFACE_TYPE_DEFINITION | Kind.INTERFACE_TYPE_EXTENSION; export type ObjectNodeKind = Kind.OBJECT_TYPE_DEFINITION | Kind.OBJECT_TYPE_EXTENSION; export type CompositeOutputNodeKind = InterfaceNodeKind | ObjectNodeKind; + +export function isValidProvidesParentData(data: ParentDefinitionData): data is ValidProvidesParentData { + return VALID_PROVIDES_PARENT_KINDS.has(data.kind); +} diff --git a/composition/src/errors/errors.ts b/composition/src/errors/errors.ts index d4a2a6ed3f..f42e4810ec 100644 --- a/composition/src/errors/errors.ts +++ b/composition/src/errors/errors.ts @@ -6,13 +6,13 @@ import { type ObjectDefinitionData, } from '../schema-building/types/types'; import { - type InvalidEntityReturnTypeErrorParams, type IncompatibleMergedTypesErrorParams, type IncompatibleParentTypeMergeErrorParams, type IncompatibleTypeWithProvidesErrorMessageParams, type InvalidArgumentValueErrorParams, type InvalidCustomDirectiveErrorParams, type InvalidDirectiveLocationErrorParams, + type InvalidEntityReturnTypeErrorParams, type InvalidLinkDirectiveImportObjectErrorParams, type InvalidNamedTypeErrorParams, type InvalidRepeatedDirectiveErrorParams, @@ -36,6 +36,7 @@ import { LITERAL_NEW_LINE, LITERAL_PERIOD, NOT_UPPER, + OBJECT, OR_UPPER, QUOTATION_JOIN, SUBSCRIPTION_FIELD_CONDITION, @@ -771,14 +772,14 @@ export function duplicateFieldInFieldSetErrorMessage(fieldSet: string, fieldPath ); } -export function incompatibleTypeWithProvidesErrorMessage({ +export function incompatibleTypeWithProvidesError({ fieldCoords, responseType, subgraphName, -}: IncompatibleTypeWithProvidesErrorMessageParams): string { - return ( +}: IncompatibleTypeWithProvidesErrorMessageParams): Error { + return new Error( ` A "@provides" directive is declared on field "${fieldCoords}" in subgraph "${subgraphName}".\n` + - ` However, the response type "${responseType}" is not an Object nor Interface.` + ` However, the response type "${responseType}" is not an Object, Interface, nor Union.`, ); } @@ -828,8 +829,7 @@ export function invalidInlineFragmentTypeErrorMessage( ` This is because an inline fragment with the type condition "${typeConditionName}" is defined on the` + ` selection set corresponding to the ` + getSelectionSetLocation(fieldCoordinatesPath, selectionSetTypeName, true) + - ` However, "${selectionSetTypeName}" is not an abstract (Interface or Union) type.\n` + - ` Consequently, the only valid type condition at this selection set would be "${selectionSetTypeName}".` + ` However, "${selectionSetTypeName}" is not an abstract (Interface or Union) type and is therefore incompatible.` ); } @@ -877,16 +877,72 @@ export function invalidInlineFragmentTypeConditionErrorMessage( typeConditionName: string, parentTypeString: string, selectionSetTypeName: string, + typeConditionTypeString: string, ): string { const message = ` The following field set is invalid:\n "${fieldSet}"\n` + ` This is because an inline fragment with the type condition "${typeConditionName}" is defined on the` + ` selection set corresponding to the ` + getSelectionSetLocationWithTypeString(fieldCoordinatesPath, selectionSetTypeName, parentTypeString); - if (parentTypeString === INTERFACE) { - return message + ` However, "${typeConditionName}" does not implement "${selectionSetTypeName}"`; + switch (parentTypeString) { + case INTERFACE: { + switch (typeConditionTypeString) { + case INTERFACE: { + return ( + message + + ` However, Interfaces "${typeConditionName}" and "${selectionSetTypeName}" are neither an` + + ` implementation of the other.` + ); + } + case OBJECT: { + return ( + message + ` However, Object "${typeConditionName}" does not implement Interface "${selectionSetTypeName}".` + ); + } + case UNION: { + return ( + message + + ` However, no members of Union "${typeConditionName}" implement Interface "${selectionSetTypeName}".` + ); + } + } + break; + } + case OBJECT: { + switch (typeConditionTypeString) { + case INTERFACE: { + return ( + message + ` However, Object "${selectionSetTypeName}" does not implement Interface "${typeConditionName}".` + ); + } + case UNION: { + return ( + message + ` However, Object "${selectionSetTypeName}" is not a member of Union "${typeConditionName}".` + ); + } + } + break; + } + case UNION: { + switch (typeConditionTypeString) { + case INTERFACE: { + return message + ` However, no members of Union "${selectionSetTypeName}" implement "${typeConditionName}".`; + } + case OBJECT: { + return ( + message + ` However, Object "${typeConditionName}" is not a member of Union "${selectionSetTypeName}".` + ); + } + case UNION: { + return ( + message + ` However, Unions "${typeConditionName}" and "${selectionSetTypeName}" share no mutual members.` + ); + } + } + break; + } } - return message + ` However, "${typeConditionName}" is not a member of "${selectionSetTypeName}".`; + return message; } export function invalidSelectionOnUnionErrorMessage( diff --git a/composition/src/schema-building/types/types.ts b/composition/src/schema-building/types/types.ts index eec1c2b8c9..45e0f9a22b 100644 --- a/composition/src/schema-building/types/types.ts +++ b/composition/src/schema-building/types/types.ts @@ -230,6 +230,8 @@ export type ChildData = EnumValueData | FieldData | InputValueData; export type CompositeOutputData = InterfaceDefinitionData | ObjectDefinitionData; +export type ValidProvidesParentData = CompositeOutputData | UnionDefinitionData; + export type DefinitionData = | DirectiveArgumentData | DirectiveDefinitionData diff --git a/composition/src/utils/string-constants.ts b/composition/src/utils/string-constants.ts index 3235f9ce05..e1221b8204 100644 --- a/composition/src/utils/string-constants.ts +++ b/composition/src/utils/string-constants.ts @@ -209,3 +209,9 @@ export const OUTPUT_NODE_KINDS = new Set([ export const INTERFACE_NODE_KINDS = new Set([Kind.INTERFACE_TYPE_DEFINITION, Kind.INTERFACE_TYPE_EXTENSION]); export const NON_REPEATABLE_FEDERATED_DIRECTIVES = new Set([INACCESSIBLE, ONE_OF, SEMANTIC_NON_NULL]); + +export const VALID_PROVIDES_PARENT_KINDS = new Set([ + Kind.INTERFACE_TYPE_DEFINITION, + Kind.OBJECT_TYPE_DEFINITION, + Kind.UNION_TYPE_DEFINITION, +]); diff --git a/composition/src/v1/normalization/normalization-factory.ts b/composition/src/v1/normalization/normalization-factory.ts index ef9d7360f0..e7ce175cdf 100644 --- a/composition/src/v1/normalization/normalization-factory.ts +++ b/composition/src/v1/normalization/normalization-factory.ts @@ -29,6 +29,7 @@ import { type InputObjectTypeNode, type InterfaceTypeNode, isKindAbstract, + isValidProvidesParentData, nodeKindToDirectiveLocation, type ObjectTypeNode, operationTypeNodeToDefaultType, @@ -61,6 +62,7 @@ import { isCompositeOutputNodeKind, isObjectDefinitionData, isObjectNodeKind, + isUnionDefinitionData, kindToConvertedTypeString, mapToArrayOfValues, newAuthorizationData, @@ -83,7 +85,7 @@ import { externalInterfaceFieldsError, fieldAlreadyProvidedErrorMessage, incompatibleInputValueDefaultValueTypeError, - incompatibleTypeWithProvidesErrorMessage, + incompatibleTypeWithProvidesError, inlineFragmentWithoutTypeConditionErrorMessage, invalidArgumentValueErrorMessage, invalidComposeDirectiveNameError, @@ -169,7 +171,7 @@ import { unimportedComposeDirectiveNameError, unknownComposeDirectiveNameError, unknownInlineFragmentTypeConditionErrorMessage, - unknownNamedTypeErrorMessage, + unknownNamedTypeError, unknownTypeInFieldSetErrorMessage, unparsableFieldSetErrorMessage, unparsableFieldSetSelectionErrorMessage, @@ -201,6 +203,7 @@ import { fieldAlreadyProvidedWarning, invalidExternalFieldWarning, nonExternalConditionalFieldWarning, + providesOnUnionWarning, singleSubgraphInputFieldOneOfWarning, unimplementedInterfaceOutputTypeWarning, } from '../warnings/warnings'; @@ -376,7 +379,6 @@ import { type EntityCacheDirectiveNode, type ExtractDirectiveArgumentDataResult, type FieldSetData, - type FieldSetParentResult, type HandleCostDirectiveParams, type HandleListSizeDirectiveParams, type HandleOverrideDirectiveParams, @@ -404,6 +406,7 @@ import { type TypeName, } from '../../types/types'; import { + type GetFieldSetParentParams, type HandleFieldInheritableDirectivesParams, type HandleNonExternalConditionalFieldParams, type NormalizationFactoryParams, @@ -413,7 +416,7 @@ import { } from './types/params'; import { EDFS_NATS_STREAM_CONFIGURATION_DEFINITION } from '../constants/non-directive-definitions'; import type { CompositionOptions } from '../../types/params'; -import { type SchemaNodeResult } from './types/results'; +import { type FieldSetParentResult, type SchemaNodeResult } from './types/results'; import { isArgumentValueValid } from '../../validation/validation'; import { type AddDirectiveArgumentDataByNodeParams, @@ -1777,14 +1780,18 @@ export class NormalizationFactory { } } - getFieldSetParent( - isProvides: boolean, - parentData: CompositeOutputData, - fieldName: string, - parentTypeName: string, - ): FieldSetParentResult { + getFieldSetParent({ + isProvides, + parentData, + fieldName, + fieldSet, + parentTypeName, + }: GetFieldSetParentParams): FieldSetParentResult { if (!isProvides) { - return { fieldSetParentData: parentData }; + return { + data: parentData, + success: true, + }; } const fieldData = getOrThrowError(parentData.fieldDataByName, fieldName, `${parentTypeName}.fieldDataByFieldName`); const fieldNamedTypeName = getTypeNodeNamedTypeName(fieldData.node.type); @@ -1792,11 +1799,12 @@ export class NormalizationFactory { if (BASE_SCALARS.has(fieldNamedTypeName)) { return { - errorString: incompatibleTypeWithProvidesErrorMessage({ + error: incompatibleTypeWithProvidesError({ fieldCoords, responseType: fieldNamedTypeName, subgraphName: this.subgraphName, }), + success: false, }; } @@ -1804,20 +1812,37 @@ export class NormalizationFactory { // This error should never happen if (!namedTypeData) { return { - errorString: unknownNamedTypeErrorMessage(fieldCoords, fieldNamedTypeName), + error: unknownNamedTypeError(fieldCoords, fieldNamedTypeName), + success: false, }; } // @TODO handle abstract types and fragments - if (namedTypeData.kind !== Kind.INTERFACE_TYPE_DEFINITION && namedTypeData.kind !== Kind.OBJECT_TYPE_DEFINITION) { + if (isValidProvidesParentData(namedTypeData)) { + if (isUnionDefinitionData(namedTypeData)) { + this.warnings.push( + providesOnUnionWarning({ + fieldCoords, + fieldSet, + namedTypeName: fieldNamedTypeName, + subgraphName: this.subgraphName, + }), + ); + } + return { - errorString: incompatibleTypeWithProvidesErrorMessage({ - fieldCoords, - responseType: fieldNamedTypeName, - subgraphName: this.subgraphName, - }), + data: namedTypeData, + success: true, }; } - return { fieldSetParentData: namedTypeData }; + + return { + error: incompatibleTypeWithProvidesError({ + fieldCoords, + responseType: fieldNamedTypeName, + subgraphName: this.subgraphName, + }), + success: false, + }; } #handleNonExternalConditionalField({ @@ -1853,7 +1878,7 @@ export class NormalizationFactory { } validateConditionalFieldSet( - selectionSetParentData: CompositeOutputData, + selectionSetParentData: CompositeOutputData | UnionDefinitionData, fieldSet: string, directiveFieldName: string, isProvides: boolean, @@ -2047,12 +2072,6 @@ export class NormalizationFactory { shouldDefineSelectionSet = true; return; } - if (!isKindAbstract(parentData.kind)) { - errorMessages.push( - invalidInlineFragmentTypeErrorMessage(fieldSet, fieldCoordsPath, typeConditionName, parentTypeName), - ); - return BREAK; - } const fragmentNamedTypeData = nf.parentDefinitionDataByTypeName.get(typeConditionName); if (!fragmentNamedTypeData) { errorMessages.push( @@ -2068,13 +2087,39 @@ export class NormalizationFactory { shouldDefineSelectionSet = true; switch (fragmentNamedTypeData.kind) { case Kind.INTERFACE_TYPE_DEFINITION: { - if (!fragmentNamedTypeData.implementedInterfaceTypeNames.has(parentTypeName)) { + // The enclosing type is an Object + if (!isKindAbstract(parentData.kind)) { + if ( + !isObjectDefinitionData(parentData) || + !parentData.implementedInterfaceTypeNames.has(typeConditionName) + ) { + break; + } + parentDatas.push(fragmentNamedTypeData); + return; + } + + // The enclosing type is an Interface or Union + const concreteTypeNames = nf.concreteTypeNamesByAbstractTypeName.get(typeConditionName); + const parentConcreteTypeNames = nf.concreteTypeNamesByAbstractTypeName.get(parentData.name); + if ( + !concreteTypeNames || + !parentConcreteTypeNames || + parentConcreteTypeNames.isDisjointFrom(concreteTypeNames) + ) { break; } + parentDatas.push(fragmentNamedTypeData); return; } case Kind.OBJECT_TYPE_DEFINITION: { + if (!isKindAbstract(parentData.kind)) { + errorMessages.push( + invalidInlineFragmentTypeErrorMessage(fieldSet, fieldCoordsPath, typeConditionName, parentTypeName), + ); + return BREAK; + } const concreteTypeNames = nf.concreteTypeNamesByAbstractTypeName.get(parentTypeName); if (!concreteTypeNames || !concreteTypeNames.has(typeConditionName)) { break; @@ -2083,6 +2128,26 @@ export class NormalizationFactory { return; } case Kind.UNION_TYPE_DEFINITION: { + const concreteTypeNames = nf.concreteTypeNamesByAbstractTypeName.get(typeConditionName); + if (!concreteTypeNames) { + break; + } + // The enclosing type is an Object + if (!isKindAbstract(parentData.kind)) { + // The enclosing Object is a valid part of the Union + if (concreteTypeNames.has(parentTypeName)) { + parentDatas.push(fragmentNamedTypeData); + return; + } + break; + } + + // The enclosing type is an Interface or Union; fetch its implementations/members respectively. + const parentConcreteTypeNames = nf.concreteTypeNamesByAbstractTypeName.get(parentTypeName); + if (!parentConcreteTypeNames || parentConcreteTypeNames.isDisjointFrom(concreteTypeNames)) { + break; + } + parentDatas.push(fragmentNamedTypeData); return; } @@ -2106,6 +2171,7 @@ export class NormalizationFactory { typeConditionName, kindToNodeType(parentData.kind), parentTypeName, + kindToNodeType(fragmentNamedTypeData.kind), ), ); return BREAK; @@ -2200,22 +2266,21 @@ export class NormalizationFactory { Consequently, at that time, it is unknown whether the named type is an entity. If it isn't, the @provides directive does not make sense and can be ignored. */ - const { fieldSetParentData, errorString } = this.getFieldSetParent( + const result = this.getFieldSetParent({ + fieldName, + fieldSet, isProvides, parentData, - fieldName, parentTypeName, - ); - const fieldCoords = `${parentTypeName}.${fieldName}`; - if (errorString) { - allErrorMessages.push(errorString); - continue; - } - if (!fieldSetParentData) { + }); + if (!result.success) { + allErrorMessages.push(result.error.message); continue; } + + const fieldCoords = `${parentTypeName}.${fieldName}`; const { errorMessages, configuration } = this.validateConditionalFieldSet( - fieldSetParentData, + result.data, fieldSet, fieldName, isProvides, diff --git a/composition/src/v1/normalization/types/params.ts b/composition/src/v1/normalization/types/params.ts index 6d22240970..ae6c3b65e7 100644 --- a/composition/src/v1/normalization/types/params.ts +++ b/composition/src/v1/normalization/types/params.ts @@ -1,4 +1,4 @@ -import { type DirectiveName, type FieldName, type SubgraphName } from '../../../types/types'; +import { type DirectiveName, type FieldName, type SubgraphName, type TypeName } from '../../../types/types'; import { type CompositeOutputData, type InputObjectDefinitionData } from '../../../schema-building/types/types'; import { type ConstDirectiveNode, type DocumentNode } from 'graphql'; import type { Subgraph } from '../../../subgraph/types'; @@ -47,3 +47,11 @@ export type NormalizeSubgraphFromStringParams = { sdlString: string; options?: CompositionOptions; }; + +export type GetFieldSetParentParams = { + fieldName: FieldName; + fieldSet: string; + isProvides: boolean; + parentData: CompositeOutputData; + parentTypeName: TypeName; +}; diff --git a/composition/src/v1/normalization/types/results.ts b/composition/src/v1/normalization/types/results.ts index 7f92921b37..d46a3c8a46 100644 --- a/composition/src/v1/normalization/types/results.ts +++ b/composition/src/v1/normalization/types/results.ts @@ -2,6 +2,11 @@ import { type DirectiveName } from '../../../types/types'; import { type ExecutionMultiFailure, type ExecutionSingleFailure, type ExecutionSuccess } from '../../../types/results'; import { type SchemaDefinitionNode, type SchemaExtensionNode } from 'graphql'; import { type LinkImportData } from './types'; +import { + CompositeOutputData, + UnionDefinitionData, + type ValidProvidesParentData, +} from '../../../schema-building/types/types'; export interface ExtractLinkArgsSuccess extends ExecutionSuccess { importDataByDirectiveName: Map; @@ -34,3 +39,9 @@ export interface ExtractLinkImportObjectSuccess extends ExecutionSuccess { } export type ExtractLinkImportObjectResult = ExecutionMultiFailure | ExtractLinkImportObjectSuccess; + +export interface FieldSetParentSuccess extends ExecutionSuccess { + data: ValidProvidesParentData; +} + +export type FieldSetParentResult = ExecutionSingleFailure | FieldSetParentSuccess; diff --git a/composition/src/v1/normalization/types/types.ts b/composition/src/v1/normalization/types/types.ts index 47528acc18..78c3888891 100644 --- a/composition/src/v1/normalization/types/types.ts +++ b/composition/src/v1/normalization/types/types.ts @@ -1,5 +1,4 @@ import { - type CompositeOutputData, type FieldData, type InputObjectDefinitionData, type InputValueData, @@ -59,11 +58,6 @@ export type ConditionalFieldSetValidationResult = { configuration?: RequiredFieldConfiguration; }; -export type FieldSetParentResult = { - errorString?: string; - fieldSetParentData?: CompositeOutputData; -}; - export type ExtractDirectiveArgumentDataResult = { argumentDataByName: Map; optionalArgumentNames: Set; diff --git a/composition/src/v1/utils/utils.ts b/composition/src/v1/utils/utils.ts index 454b856c4b..8c733b01f1 100644 --- a/composition/src/v1/utils/utils.ts +++ b/composition/src/v1/utils/utils.ts @@ -7,10 +7,10 @@ import type { EntityInterfaceSubgraphData, FieldAuthorizationData, FieldData, - NodeData, ObjectDefinitionData, ParentDefinitionData, SimpleFieldData, + UnionDefinitionData, } from '../../schema-building/types/types'; import { BOOLEAN_SCALAR, @@ -459,3 +459,10 @@ export function isObjectDefinitionData(data?: ParentDefinitionData): data is Obj } return data.kind === Kind.OBJECT_TYPE_DEFINITION; } + +export function isUnionDefinitionData(data?: ParentDefinitionData): data is UnionDefinitionData { + if (!data) { + return false; + } + return data.kind === Kind.UNION_TYPE_DEFINITION; +} diff --git a/composition/src/v1/warnings/params.ts b/composition/src/v1/warnings/params.ts index 5e5b5ece75..d2925b2b06 100644 --- a/composition/src/v1/warnings/params.ts +++ b/composition/src/v1/warnings/params.ts @@ -16,3 +16,10 @@ export type InvalidRepeatedComposedDirectiveWarningParams = { directiveName: DirectiveName; printedDirective: string; }; + +export type ProvidesOnUnionWarningParams = { + fieldCoords: string; + fieldSet: string; + namedTypeName: TypeName; + subgraphName: SubgraphName; +}; diff --git a/composition/src/v1/warnings/warnings.ts b/composition/src/v1/warnings/warnings.ts index 4089288f94..cac56cfdda 100644 --- a/composition/src/v1/warnings/warnings.ts +++ b/composition/src/v1/warnings/warnings.ts @@ -2,6 +2,7 @@ import { Warning } from '../../warnings/types'; import { QUOTATION_JOIN } from '../../utils/string-constants'; import { type InvalidRepeatedComposedDirectiveWarningParams, + type ProvidesOnUnionWarningParams, type SingleFederatedInputFieldOneOfWarningParams, type SingleSubgraphInputFieldOneOfWarningParams, } from './params'; @@ -236,3 +237,21 @@ export function invalidRepeatedComposedDirectiveWarning({ }, }); } + +export function providesOnUnionWarning({ + fieldCoords, + fieldSet, + namedTypeName, + subgraphName, +}: ProvidesOnUnionWarningParams): Warning { + return new Warning({ + message: + `The field "${fieldCoords}" that returns union "${namedTypeName}" defines a "@provides" directive with the` + + ` following field set:\n "${fieldSet}"\n` + + `The "@provides" directive defined on a field that returns a Union type is only supported by router version` + + ` 0.326.3+. Please note that older router versions do not support this functionality.`, + subgraph: { + name: subgraphName, + }, + }); +} diff --git a/composition/tests/v1/directives/fieldset-directives.test.ts b/composition/tests/v1/directives/fieldset-directives.test.ts index 4593cc3f4f..0a63017009 100644 --- a/composition/tests/v1/directives/fieldset-directives.test.ts +++ b/composition/tests/v1/directives/fieldset-directives.test.ts @@ -11,7 +11,6 @@ import { INTERFACE, invalidDirectiveError, invalidInlineFragmentTypeConditionErrorMessage, - invalidInlineFragmentTypeErrorMessage, invalidProvidesOrRequiresDirectivesError, invalidSelectionOnUnionErrorMessage, invalidSelectionSetDefinitionErrorMessage, @@ -383,7 +382,8 @@ describe('openfed_FieldSet tests', () => { expect(errors).toHaveLength(1); expect(errors[0]).toStrictEqual( invalidProvidesOrRequiresDirectivesError(REQUIRES, [ - ` On field "Entity.age":\n -` + invalidInlineFragmentTypeErrorMessage('... on I { name }', [], 'I', 'Entity'), + ` On field "Entity.age":\n -` + + invalidInlineFragmentTypeConditionErrorMessage('... on I { name }', [], 'I', OBJECT, 'Entity', INTERFACE), ]), ); expect(warnings).toHaveLength(0); @@ -479,6 +479,7 @@ describe('openfed_FieldSet tests', () => { OBJECT, INTERFACE, 'I', + OBJECT, ), ]), ); @@ -539,6 +540,7 @@ describe('openfed_FieldSet tests', () => { 'AnotherObject', UNION, 'U', + OBJECT, ), ]), ); diff --git a/composition/tests/v1/directives/provides.test.ts b/composition/tests/v1/directives/provides.test.ts index e8f1939d60..3144f05321 100644 --- a/composition/tests/v1/directives/provides.test.ts +++ b/composition/tests/v1/directives/provides.test.ts @@ -6,24 +6,27 @@ import { fieldAlreadyProvidedErrorMessage, fieldAlreadyProvidedWarning, ID_SCALAR, - incompatibleTypeWithProvidesErrorMessage, + incompatibleTypeWithProvidesError, INTERFACE, invalidInlineFragmentTypeConditionErrorMessage, - invalidInlineFragmentTypeErrorMessage, invalidProvidesOrRequiresDirectivesError, invalidSelectionOnUnionErrorMessage, nonExternalConditionalFieldError, nonExternalConditionalFieldWarning, + OBJECT, parse, PROVIDES, + providesOnUnionWarning, ROUTER_COMPATIBILITY_VERSION_ONE, type Subgraph, subgraphValidationError, type TypeName, typeNameAlreadyProvidedErrorMessage, UNION, + unknownInlineFragmentTypeConditionErrorMessage, } from '../../../src'; import { + createSubgraph, federateSubgraphsFailure, federateSubgraphsSuccess, normalizeSubgraphFailure, @@ -38,11 +41,11 @@ describe('@provides directive tests', () => { expect(configurationDataByTypeName).toStrictEqual( new Map([ [ - 'Object', + OBJECT, { fieldNames: new Set(['id']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -55,12 +58,12 @@ describe('@provides directive tests', () => { expect(configurationDataByTypeName).toStrictEqual( new Map([ [ - 'Object', + OBJECT, { fieldNames: new Set(['entity']), isRootNode: false, provides: [{ fieldName: 'entity', selectionSet: '... on Entity { name }' }], - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -84,11 +87,13 @@ describe('@provides directive tests', () => { expect(errors[0]).toStrictEqual( invalidProvidesOrRequiresDirectivesError(PROVIDES, [ ` On field "Object.entity":\n -` + - invalidInlineFragmentTypeErrorMessage( + invalidInlineFragmentTypeConditionErrorMessage( '... on Interface { name }', ['Object.entity'], 'Interface', + OBJECT, 'Entity', + INTERFACE, ), ]), ); @@ -100,14 +105,14 @@ describe('@provides directive tests', () => { expect(configurationDataByTypeName).toStrictEqual( new Map([ [ - 'Object', + OBJECT, { fieldNames: new Set(['entity']), isRootNode: false, provides: [ { fieldName: 'entity', selectionSet: 'interface { ... on Interface { ... on Interface { name } } }' }, ], - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -146,12 +151,12 @@ describe('@provides directive tests', () => { expect(configurationDataByTypeName).toStrictEqual( new Map([ [ - 'Object', + OBJECT, { fieldNames: new Set(['entity']), isRootNode: false, provides: [{ fieldName: 'entity', selectionSet: 'interface { ... on AnotherObject { name } }' }], - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -197,6 +202,7 @@ describe('@provides directive tests', () => { 'AnotherObject', INTERFACE, 'Interface', + OBJECT, ), ]), ); @@ -208,12 +214,12 @@ describe('@provides directive tests', () => { expect(configurationDataByTypeName).toStrictEqual( new Map([ [ - 'Object', + OBJECT, { fieldNames: new Set(['entity']), isRootNode: false, provides: [{ fieldName: 'entity', selectionSet: 'union { ... on AnotherObject { name } }' }], - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -263,6 +269,7 @@ describe('@provides directive tests', () => { 'YetAnotherObject', UNION, 'Union', + OBJECT, ), ]), ); @@ -274,12 +281,12 @@ describe('@provides directive tests', () => { expect(configurationDataByTypeName).toStrictEqual( new Map([ [ - 'Object', + OBJECT, { fieldNames: new Set(['entity']), isRootNode: false, provides: [{ fieldName: 'entity', selectionSet: 'anotherObject { name }' }], - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -310,12 +317,12 @@ describe('@provides directive tests', () => { expect(configurationDataByTypeName).toStrictEqual( new Map([ [ - 'Object', + OBJECT, { fieldNames: new Set(['entity']), isRootNode: false, provides: [{ fieldName: 'entity', selectionSet: 'anotherObject(arg: "string") { name }' }], - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -355,12 +362,12 @@ describe('@provides directive tests', () => { { fieldCoordinatesPath: ['Query.entity', 'Entity.object', 'Object.nestedObject', 'NestedObject.age'], fieldPath: ['entity', 'object', 'nestedObject', 'age'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, { fieldCoordinatesPath: ['Query.entities', 'Entity.object', 'Object.nestedObject', 'NestedObject.age'], fieldPath: ['entities', 'object', 'nestedObject', 'age'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, ], requiredBy: [], @@ -373,12 +380,12 @@ describe('@provides directive tests', () => { { fieldCoordinatesPath: ['Query.entity', 'Entity.object', 'Object.nestedObject', 'NestedObject.name'], fieldPath: ['entity', 'object', 'nestedObject', 'name'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, { fieldCoordinatesPath: ['Query.entities', 'Entity.object', 'Object.nestedObject', 'NestedObject.name'], fieldPath: ['entities', 'object', 'nestedObject', 'name'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, ], requiredBy: [], @@ -403,12 +410,12 @@ describe('@provides directive tests', () => { { fieldCoordinatesPath: ['Query.entity', 'Entity.object', 'Object.nestedObject', 'NestedObject.age'], fieldPath: ['entity', 'object', 'nestedObject', 'age'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, { fieldCoordinatesPath: ['Query.entities', 'Entity.object', 'Object.nestedObject', 'NestedObject.age'], fieldPath: ['entities', 'object', 'nestedObject', 'age'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, ], requiredBy: [], @@ -421,12 +428,12 @@ describe('@provides directive tests', () => { { fieldCoordinatesPath: ['Query.entity', 'Entity.object', 'Object.nestedObject', 'NestedObject.name'], fieldPath: ['entity', 'object', 'nestedObject', 'name'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, { fieldCoordinatesPath: ['Query.entities', 'Entity.object', 'Object.nestedObject', 'NestedObject.name'], fieldPath: ['entities', 'object', 'nestedObject', 'name'], - // typePath: ['Query', 'Entity', 'Object', 'NestedObject'], + // typePath: ['Query', 'Entity', OBJECT, 'NestedObject'], }, ], requiredBy: [], @@ -555,11 +562,11 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { fieldNames: new Set(['id']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -610,12 +617,12 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { externalFieldNames: new Set(['id', 'name']), fieldNames: new Set(), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -663,11 +670,11 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { fieldNames: new Set(['id', 'name']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -715,12 +722,12 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { externalFieldNames: new Set(['id']), fieldNames: new Set(['name']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -777,12 +784,12 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { externalFieldNames: new Set(['id']), fieldNames: new Set(['nestedObject']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -795,11 +802,11 @@ describe('@provides directive tests', () => { expect(errors).toHaveLength(1); expect(errors[0]).toStrictEqual( invalidProvidesOrRequiresDirectivesError(PROVIDES, [ - incompatibleTypeWithProvidesErrorMessage({ + incompatibleTypeWithProvidesError({ fieldCoords: 'Query.a', responseType: ID_SCALAR, subgraphName: nakaa.name, - }), + }).message, ]), ); }); @@ -860,11 +867,11 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { fieldNames: new Set(['nestedObject']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -914,11 +921,11 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { fieldNames: new Set(['nestedObject']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], [ @@ -1070,7 +1077,7 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { fieldNames: new Set(['id', 'object']), externalFieldNames: new Set(['name']), @@ -1081,7 +1088,7 @@ describe('@provides directive tests', () => { selectionSet: 'name', }, ], - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -1155,12 +1162,12 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { fieldNames: new Set(['id', 'object']), externalFieldNames: new Set(['name']), isRootNode: false, - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -1200,7 +1207,7 @@ describe('@provides directive tests', () => { }, ], [ - 'Object', + OBJECT, { fieldNames: new Set(['id']), isRootNode: true, @@ -1210,7 +1217,7 @@ describe('@provides directive tests', () => { selectionSet: 'id', }, ], - typeName: 'Object', + typeName: OBJECT, }, ], ]), @@ -1236,6 +1243,550 @@ describe('@provides directive tests', () => { expect(warnings[1]).toStrictEqual(externalEntityExtensionKeyFieldWarning(`Object`, `id`, [`Object.id`], af.name)); }); + test('that provides on a field that returns a Union is valid #1', () => { + const subgraphA = createSubgraph( + 'a', + ` + type Query { + union: Union @provides(fields: "... on Entity { name }") + } + + type Entity @key(fields: "id") { + id: ID! + name: String! @external @shareable + } + + union Union = Entity + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + type Entity @key(fields: "id") { + id: ID! + name: String! @shareable + } + `, + ); + const { warnings } = federateSubgraphsSuccess([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(warnings).toHaveLength(1); + expect(warnings).toStrictEqual([ + providesOnUnionWarning({ + fieldCoords: 'Query.union', + fieldSet: '... on Entity { name }', + namedTypeName: UNION, + subgraphName: subgraphA.name, + }), + ]); + }); + + test('that provides on a field that returns a Union is valid #2', () => { + const subgraphA = createSubgraph( + 'a', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external"] + ) + + type Query { + media: [Media] @shareable + } + + union Media = Book | Movie + + type Book @key(fields: "id") { + id: ID! + } + + type Movie @key(fields: "id") { + id: ID! + } + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@provides", "@external"] + ) + + type Query { + media: [Media] @shareable @provides(fields: "... on Book { title }") + } + + union Media = Book | Movie + + type Book @key(fields: "id") { + id: ID! + title: String @external + } + + type Movie @key(fields: "id") { + id: ID! + } + `, + ); + const subgraphC = createSubgraph( + 'c', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + + type Book @key(fields: "id") { + id: ID! + title: String @shareable + } + + type Movie @key(fields: "id") { + id: ID! + title: String @shareable + } + `, + ); + const { warnings } = federateSubgraphsSuccess( + [subgraphA, subgraphB, subgraphC], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(warnings).toHaveLength(1); + expect(warnings).toStrictEqual([ + providesOnUnionWarning({ + fieldCoords: 'Query.media', + fieldSet: '... on Book { title }', + namedTypeName: 'Media', + subgraphName: subgraphB.name, + }), + ]); + }); + + test('that an error is returned if a non-external field is provided through a Union type fragment', () => { + const subgraphA = createSubgraph( + 'a', + ` + type Query { + union: Union @provides(fields: "... on Entity { name }") + } + + type Entity @key(fields: "id") { + id: ID! + name: String! @shareable + } + + union Union = Entity + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + type Entity @key(fields: "id") { + id: ID! + name: String! @shareable + } + `, + ); + const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(errors).toHaveLength(1); + expect(errors).toStrictEqual([ + subgraphValidationError(subgraphA.name, [ + nonExternalConditionalFieldError({ + directiveCoords: 'Query.union', + fieldSet: `... on Entity { name }`, + directiveName: PROVIDES, + subgraphName: subgraphA.name, + targetCoords: 'Entity.name', + }), + ]), + ]); + expect(warnings).toHaveLength(1); + expect(warnings).toStrictEqual([ + providesOnUnionWarning({ + fieldCoords: 'Query.union', + fieldSet: '... on Entity { name }', + namedTypeName: UNION, + subgraphName: subgraphA.name, + }), + ]); + }); + + test('that an error is returned if a Union fields et defines an unknown type fragment', () => { + const subgraphA = createSubgraph( + 'a', + ` + type Query { + union: Union @provides(fields: "... on Unknown { name }") + } + + type Entity @key(fields: "id") { + id: ID! + name: String! @shareable + } + + union Union = Entity + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + type Entity @key(fields: "id") { + id: ID! + name: String! @shareable + } + `, + ); + const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(errors).toHaveLength(1); + expect(errors).toStrictEqual([ + subgraphValidationError(subgraphA.name, [ + invalidProvidesOrRequiresDirectivesError(PROVIDES, [ + ` On field "Query.union":\n -` + + unknownInlineFragmentTypeConditionErrorMessage( + `... on Unknown { name }`, + ['Query.union'], + UNION, + 'Unknown', + ), + ]), + ]), + ]); + expect(warnings).toStrictEqual([ + providesOnUnionWarning({ + fieldCoords: 'Query.union', + fieldSet: '... on Unknown { name }', + namedTypeName: UNION, + subgraphName: subgraphA.name, + }), + ]); + }); + + test('that an error is returned if a Union field set defines a non-member type fragment', () => { + const subgraphA = createSubgraph( + 'a', + ` + type Query { + union: Union @provides(fields: "... on EntityB { name }") + } + + type EntityA @key(fields: "id") { + id: ID! + name: String! @shareable + } + + type EntityB @key(fields: "id") { + id: ID! + } + + union Union = EntityA + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + type EntityA @key(fields: "id") { + id: ID! + name: String! @shareable + } + `, + ); + const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(errors).toHaveLength(1); + expect(errors).toStrictEqual([ + subgraphValidationError(subgraphA.name, [ + invalidProvidesOrRequiresDirectivesError(PROVIDES, [ + ` On field "Query.union":\n -` + + invalidInlineFragmentTypeConditionErrorMessage( + `... on EntityB { name }`, + ['Query.union'], + 'EntityB', + UNION, + UNION, + OBJECT, + ), + ]), + ]), + ]); + expect(warnings).toStrictEqual([ + providesOnUnionWarning({ + fieldCoords: 'Query.union', + fieldSet: '... on EntityB { name }', + namedTypeName: UNION, + subgraphName: subgraphA.name, + }), + ]); + }); + + test('that an error is returned if a Union field set defines a non-member type fragment (in the current subgraph)', () => { + const subgraphA = createSubgraph( + 'a', + ` + type Query { + union: Union @provides(fields: "... on EntityB { name }") + } + + type EntityA @key(fields: "id") { + id: ID! + name: String! @shareable + } + + type EntityB @key(fields: "id") { + id: ID! + } + + union Union = EntityA + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + type EntityA @key(fields: "id") { + id: ID! + name: String! @shareable + } + + type EntityB @key(fields: "id") { + id: ID! + } + + union Union = EntityA | EntityB + `, + ); + const { errors, warnings } = federateSubgraphsFailure([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(errors).toHaveLength(1); + expect(errors).toStrictEqual([ + subgraphValidationError(subgraphA.name, [ + invalidProvidesOrRequiresDirectivesError(PROVIDES, [ + ` On field "Query.union":\n -` + + invalidInlineFragmentTypeConditionErrorMessage( + `... on EntityB { name }`, + ['Query.union'], + 'EntityB', + UNION, + UNION, + OBJECT, + ), + ]), + ]), + ]); + expect(warnings).toHaveLength(1); + expect(warnings).toStrictEqual([ + providesOnUnionWarning({ + fieldCoords: 'Query.union', + fieldSet: '... on EntityB { name }', + namedTypeName: UNION, + subgraphName: subgraphA.name, + }), + ]); + }); + + test('that a valid Interface type fragment on a concrete selection is valid', () => { + const subgraphA = createSubgraph( + 'a', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external"] + ) + + type Query { + entities: [Entity!]! @provides(fields: "object { ... on Interface { ... on Object { id } }}") + } + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! @external + } + + interface Interface { + id: ID + } + + type Object implements Interface @shareable { + id: ID + } + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@provides", "@external"] + ) + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! + } + + type Object @shareable { + id: ID + } + `, + ); + const { warnings } = federateSubgraphsSuccess([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(warnings).toHaveLength(0); + }); + + test('that a valid Union type fragment on a concrete selection is valid', () => { + const subgraphA = createSubgraph( + 'a', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external"] + ) + + type Query { + entities: [Entity!]! @provides(fields: "object { ... on Union { ... on Object { id } } }") + } + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! @external + } + + type Object @shareable { + id: ID + } + + union Union = Object + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@provides", "@external"] + ) + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! + } + + type Object @shareable { + id: ID + } + `, + ); + const { warnings } = federateSubgraphsSuccess([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(warnings).toHaveLength(0); + }); + + test('that a valid Union type fragment on a Union selection is valid', () => { + const subgraphA = createSubgraph( + 'a', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external"] + ) + + type Query { + entities: [Entity!]! @provides(fields: "object { ... on UnionA { ... on UnionB { ... on Object { id } } } }") + } + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! @external + } + + type Object @shareable { + id: ID + } + + union UnionA = Object + + union UnionB = Object + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@provides", "@external"] + ) + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! + } + + type Object @shareable { + id: ID + } + `, + ); + const { warnings } = federateSubgraphsSuccess([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(warnings).toHaveLength(0); + }); + + test('that a valid Interface type fragment on a Union selection is valid', () => { + const subgraphA = createSubgraph( + 'a', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@external"] + ) + + type Query { + entities: [Entity!]! @provides(fields: "object { ... on Union { ... on Interface { ... on Object { id } } } }") + } + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! @external + } + + interface Interface { + id: ID + } + + type Object implements Interface @shareable { + id: ID + } + + union Union = Object + `, + ); + const subgraphB = createSubgraph( + 'b', + ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@provides", "@external"] + ) + + type Entity @key(fields: "id") @shareable { + id: ID! + object: Object! + } + + type Object @shareable { + id: ID + } + `, + ); + const { warnings } = federateSubgraphsSuccess([subgraphA, subgraphB], ROUTER_COMPATIBILITY_VERSION_ONE); + expect(warnings).toHaveLength(0); + }); + // TODO test.skip('that provides on Interface is valid', () => { const { warnings } = federateSubgraphsSuccess([q, r, s], ROUTER_COMPATIBILITY_VERSION_ONE);