diff --git a/.changeset/little-hairs-kiss.md b/.changeset/little-hairs-kiss.md new file mode 100644 index 00000000000..76872a58790 --- /dev/null +++ b/.changeset/little-hairs-kiss.md @@ -0,0 +1,7 @@ +--- +'@graphql-tools/stitch': minor +'@graphql-tools/stitching-directives': patch +--- + +Adding the ability to return non-scalar types from computed fields. Computed fields can now return +object types (local or stitched), interfaces, unions, or enums. diff --git a/packages/federation/test/__snapshots__/supergraphs.test.ts.snap b/packages/federation/test/__snapshots__/supergraphs.test.ts.snap index 8b07b5a52ec..3cf55ea0148 100644 --- a/packages/federation/test/__snapshots__/supergraphs.test.ts.snap +++ b/packages/federation/test/__snapshots__/supergraphs.test.ts.snap @@ -199,11 +199,6 @@ type Query { review(id: Int!): Review } -type DeliveryEstimates { - estimatedDelivery: String - fastestDelivery: String -} - type Product implements ProductItf & SkuItf { id: ID! @tag(name: "hi-from-products") delivery(zip: String): DeliveryEstimates @@ -246,6 +241,11 @@ enum ShippingClass { OVERNIGHT } +type DeliveryEstimates { + estimatedDelivery: String + fastestDelivery: String +} + type Panda { name: ID! favoriteFood: String @tag(name: "nom-nom-nom") diff --git a/packages/stitch/src/createDelegationPlanBuilder.ts b/packages/stitch/src/createDelegationPlanBuilder.ts index 03d9ec298fb..e21c6cedcb2 100644 --- a/packages/stitch/src/createDelegationPlanBuilder.ts +++ b/packages/stitch/src/createDelegationPlanBuilder.ts @@ -68,7 +68,29 @@ function calculateDelegationStage( const delegationMap: Map = new Map(); for (const fieldNode of fieldNodes) { - if (fieldNode.name.value === '__typename') { + const fieldName = fieldNode.name.value; + if (fieldName === '__typename') { + continue; + } + + // check dependencies for computed fields are available in the source schemas + const sourcesWithUnsatisfiedDependencies = sourceSubschemas.filter( + s => + fieldSelectionSets.get(s) != null && + fieldSelectionSets.get(s)![fieldName] != null && + !subschemaTypesContainSelectionSet( + mergedTypeInfo, + sourceSubschemas, + fieldSelectionSets.get(s)![fieldName], + ), + ); + if (sourcesWithUnsatisfiedDependencies.length === sourceSubschemas.length) { + unproxiableFieldNodes.push(fieldNode); + for (const source of sourcesWithUnsatisfiedDependencies) { + if (!nonProxiableSubschemas.includes(source)) { + nonProxiableSubschemas.push(source); + } + } continue; } diff --git a/packages/stitch/src/getFieldsNotInSubschema.ts b/packages/stitch/src/getFieldsNotInSubschema.ts index f75bc80f968..f745dbc3164 100644 --- a/packages/stitch/src/getFieldsNotInSubschema.ts +++ b/packages/stitch/src/getFieldsNotInSubschema.ts @@ -27,6 +27,7 @@ export function getFieldsNotInSubschema( const fieldsNotInSchema = new Set(); for (const [, subFieldNodes] of subFieldNodesByResponseKey) { const fieldName = subFieldNodes[0].name.value; + if (!fields[fieldName]) { for (const subFieldNode of subFieldNodes) { fieldsNotInSchema.add(subFieldNode); @@ -35,7 +36,12 @@ export function getFieldsNotInSubschema( const fieldNodesForField = fieldNodesByField?.[gatewayType.name]?.[fieldName]; if (fieldNodesForField) { for (const fieldNode of fieldNodesForField) { - if (!fields[fieldNode.name.value]) { + if (fieldNode.name.value !== '__typename' && !fields[fieldNode.name.value]) { + // consider node that depends on something not in the schema as not in the schema + for (const subFieldNode of subFieldNodes) { + fieldsNotInSchema.add(subFieldNode); + } + fieldsNotInSchema.add(fieldNode); } } diff --git a/packages/stitch/src/subschemaConfigTransforms/isolateComputedFieldsTransformer.ts b/packages/stitch/src/subschemaConfigTransforms/isolateComputedFieldsTransformer.ts index a136caeb458..b3cb9277f45 100644 --- a/packages/stitch/src/subschemaConfigTransforms/isolateComputedFieldsTransformer.ts +++ b/packages/stitch/src/subschemaConfigTransforms/isolateComputedFieldsTransformer.ts @@ -1,7 +1,28 @@ -import { GraphQLObjectType, isInterfaceType, isObjectType } from 'graphql'; +import { + getNamedType, + GraphQLNamedOutputType, + GraphQLObjectType, + GraphQLSchema, + isCompositeType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType, +} from 'graphql'; import { MergedFieldConfig, MergedTypeConfig, SubschemaConfig } from '@graphql-tools/delegate'; -import { filterSchema, getImplementingTypes, pruneSchema } from '@graphql-tools/utils'; -import { TransformCompositeFields } from '@graphql-tools/wrap'; +import { + collectFields, + filterSchema, + getImplementingTypes, + parseSelectionSet, + pruneSchema, +} from '@graphql-tools/utils'; +import { FilterTypes, TransformCompositeFields } from '@graphql-tools/wrap'; + +interface ComputedTypeConfig> + extends MergedTypeConfig { + keyFieldNames: string[]; +} export function isolateComputedFieldsTransformer( subschemaConfig: SubschemaConfig, @@ -11,10 +32,11 @@ export function isolateComputedFieldsTransformer( } const baseSchemaTypes: Record = Object.create(null); - const isolatedSchemaTypes: Record = Object.create(null); + const isolatedSchemaTypes: Record = Object.create(null); for (const typeName in subschemaConfig.merge) { const mergedTypeConfig = subschemaConfig.merge[typeName]; + const objectType = subschemaConfig.schema.getType(typeName) as GraphQLObjectType; baseSchemaTypes[typeName] = mergedTypeConfig; @@ -36,7 +58,6 @@ export function isolateComputedFieldsTransformer( } const isolatedFieldCount = Object.keys(isolatedFields).length; - const objectType = subschemaConfig.schema.getType(typeName) as GraphQLObjectType; if (isolatedFieldCount && isolatedFieldCount !== Object.keys(objectType.getFields()).length) { baseSchemaTypes[typeName] = { @@ -45,9 +66,70 @@ export function isolateComputedFieldsTransformer( }; isolatedSchemaTypes[typeName] = { ...mergedTypeConfig, - fields: isolatedFields, + // there might already be key fields + keyFieldNames: isolatedSchemaTypes[typeName]?.keyFieldNames || [], + fields: { ...(isolatedSchemaTypes[typeName]?.fields ?? {}), ...isolatedFields }, canonical: undefined, }; + + for (const fieldName in isolatedFields) { + const returnType = getNamedType(objectType.getFields()[fieldName].type); + const returnTypes: GraphQLNamedOutputType[] = [returnType]; + + // for interfaces and unions the implementations/members need to be handled as well + if (isInterfaceType(returnType)) { + returnTypes.push( + ...getImplementingTypes(returnType.name, subschemaConfig.schema).map( + name => subschemaConfig.schema.getType(name)! as GraphQLNamedOutputType, + ), + ); + } else if (isUnionType(returnType)) { + returnTypes.push(...returnType.getTypes()); + } + + for (const type of returnTypes) { + const returnTypeMergeConfig = subschemaConfig.merge[type.name]; + + if (isObjectType(type)) { + if (returnTypeMergeConfig?.selectionSet) { + // this is a merged type, include the selection set + // TODO: how to handle entryPoints + const keyFieldNames: string[] = []; + if (isObjectType(type)) { + const parsedSelectionSet = parseSelectionSet(returnTypeMergeConfig.selectionSet!); + const keyFields = collectFields( + subschemaConfig.schema, + {}, + {}, + type, + parsedSelectionSet, + ); + keyFieldNames.push(...Array.from(keyFields.fields.keys())); + } + + isolatedSchemaTypes[type.name] = { + ...returnTypeMergeConfig, + keyFieldNames, + fields: { + ...(isolatedSchemaTypes[type.name]?.fields ?? {}), + }, + }; + } else if (!returnTypeMergeConfig) { + // this is an unmerged type, add all fields to the isolated schema + const fields = isUnionType(type) ? {} : type.getFields(); + + isolatedSchemaTypes[type.name] = { + keyFieldNames: [], + fields: { + ...(isolatedSchemaTypes[type.name]?.fields ?? {}), + ...Object.fromEntries(Object.keys(fields).map(f => [f, {}])), + }, + canonical: true, + }; + } + } + } + } } } } @@ -62,9 +144,28 @@ export function isolateComputedFieldsTransformer( return [subschemaConfig]; } +function _createCompositeFieldFilter(schema: GraphQLSchema) { + // create TransformCompositeFields that will remove any field not in schema, + const filteredFields: Record> = {}; + for (const typeName in schema.getTypeMap()) { + const type = schema.getType(typeName); + if (isObjectType(type) || isInterfaceType(type)) { + filteredFields[typeName] = { __typename: true }; + const fieldMap = type.getFields(); + for (const fieldName in fieldMap) { + filteredFields[typeName][fieldName] = true; + } + } + } + return new TransformCompositeFields( + (typeName, fieldName) => (filteredFields[typeName]?.[fieldName] ? undefined : null), + (typeName, fieldName) => (filteredFields[typeName]?.[fieldName] ? undefined : null), + ); +} + function filterBaseSubschema( subschemaConfig: SubschemaConfig, - isolatedSchemaTypes: Record, + isolatedSchemaTypes: Record, ): SubschemaConfig { const schema = subschemaConfig.schema; const typesForInterface: Record = {}; @@ -72,7 +173,8 @@ function filterBaseSubschema( filterSchema({ schema, objectFieldFilter: (typeName, fieldName) => - !isolatedSchemaTypes[typeName]?.fields?.[fieldName], + !isolatedSchemaTypes[typeName]?.fields?.[fieldName] || + (isolatedSchemaTypes[typeName]?.keyFieldNames ?? []).includes(fieldName), interfaceFieldFilter: (typeName, fieldName) => { if (!typesForInterface[typeName]) { typesForInterface[typeName] = getImplementingTypes(typeName, schema); @@ -84,18 +186,6 @@ function filterBaseSubschema( }), ); - const filteredFields: Record> = {}; - for (const typeName in filteredSchema.getTypeMap()) { - const type = filteredSchema.getType(typeName); - if (isObjectType(type) || isInterfaceType(type)) { - filteredFields[typeName] = { __typename: true }; - const fieldMap = type.getFields(); - for (const fieldName in fieldMap) { - filteredFields[typeName][fieldName] = true; - } - } - } - const filteredSubschema = { ...subschemaConfig, merge: subschemaConfig.merge @@ -104,9 +194,11 @@ function filterBaseSubschema( } : undefined, transforms: (subschemaConfig.transforms ?? []).concat([ - new TransformCompositeFields( - (typeName, fieldName) => (filteredFields[typeName]?.[fieldName] ? undefined : null), - (typeName, fieldName) => (filteredFields[typeName]?.[fieldName] ? undefined : null), + _createCompositeFieldFilter(filteredSchema), + new FilterTypes( // filter out empty types + type => + (!isObjectType(type) && !isInterfaceType(type)) || + Object.keys(type.getFields()).length > 0, ), ]), }; @@ -129,11 +221,71 @@ function filterBaseSubschema( } type IsolatedSubschemaInput = Exclude & { - merge: Exclude; + merge: Record; }; function filterIsolatedSubschema(subschemaConfig: IsolatedSubschemaInput): SubschemaConfig { const rootFields: Record = {}; + const computedFieldTypes: Record = {}; // contains types of computed fields that have no root field + + function listReachableTypesToIsolate( + subschemaConfig: SubschemaConfig, + type: GraphQLNamedOutputType, + typeNames?: string[], + ) { + typeNames = typeNames || []; + + if (isScalarType(type)) { + return typeNames; + } else if ( + (isObjectType(type) || isInterfaceType(type)) && + subschemaConfig.merge && + subschemaConfig.merge[type.name] && + subschemaConfig.merge[type.name].selectionSet + ) { + // this is a merged type, no need to descend further + if (!typeNames.includes(type.name)) { + typeNames.push(type.name); + } + return typeNames; + } else if (isCompositeType(type)) { + if (!typeNames.includes(type.name)) { + typeNames.push(type.name); + } + + // descent into all field types potentially via interfaces implementations/unions members + const types: GraphQLObjectType[] = []; + if (isObjectType(type)) { + types.push(type); + } else if (isInterfaceType(type)) { + types.push( + ...getImplementingTypes(type.name, subschemaConfig.schema).map( + name => subschemaConfig.schema.getType(name)! as GraphQLObjectType, + ), + ); + } else if (isUnionType(type)) { + types.push(...type.getTypes()); + } + + for (const type of types) { + if (!typeNames.includes(type.name)) { + typeNames.push(type.name); + } + + for (const f of Object.values(type.getFields())) { + const fieldType = getNamedType(f.type); + if (!typeNames.includes(fieldType.name) && isCompositeType(fieldType)) { + typeNames.push(...listReachableTypesToIsolate(subschemaConfig, fieldType)); + } + } + } + + return typeNames; + } else { + // TODO: Unions + return typeNames; + } + } for (const typeName in subschemaConfig.merge) { const mergedTypeConfig = subschemaConfig.merge[typeName]; @@ -141,25 +293,47 @@ function filterIsolatedSubschema(subschemaConfig: IsolatedSubschemaInput): Subsc for (const entryPoint of entryPoints) { if (entryPoint.fieldName != null) { rootFields[entryPoint.fieldName] = true; + if (computedFieldTypes[entryPoint.fieldName]) { + delete computedFieldTypes[entryPoint.fieldName]; + } } } + const computedFields = [ + ...Object.entries(mergedTypeConfig.fields || {}) + .map(([k, v]) => (v.computed ? k : null)) + .filter(fn => fn !== null), + ].filter(fn => !rootFields[fn!]); + + const type = subschemaConfig.schema.getType(typeName) as GraphQLObjectType; + + for (const fieldName of computedFields) { + const fieldType = getNamedType(type.getFields()[fieldName!].type); + listReachableTypesToIsolate(subschemaConfig, fieldType).forEach(tn => { + computedFieldTypes[tn] = true; + }); + } } const interfaceFields: Record> = {}; for (const typeName in subschemaConfig.merge) { const type = subschemaConfig.schema.getType(typeName); - if (!type || !('getInterfaces' in type)) { - throw new Error(`${typeName} expected to have 'getInterfaces' method`); - } - for (const int of type.getInterfaces()) { - const intType = subschemaConfig.schema.getType(int.name); - if (!intType || !('getFields' in intType)) { - throw new Error(`${int.name} expected to have 'getFields' method`); + if (!type || isObjectType(type)) { + if (!type || !('getInterfaces' in type)) { + throw new Error(`${typeName} expected to have 'getInterfaces' method`); } - for (const intFieldName in intType.getFields()) { - if (subschemaConfig.merge[typeName].fields?.[intFieldName]) { - interfaceFields[int.name] = interfaceFields[int.name] || {}; - interfaceFields[int.name][intFieldName] = true; + for (const int of type.getInterfaces()) { + const intType = subschemaConfig.schema.getType(int.name); + if (!intType || !('getFields' in intType)) { + throw new Error(`${int.name} expected to have 'getFields' method`); + } + for (const intFieldName in intType.getFields()) { + if ( + subschemaConfig.merge[typeName].fields?.[intFieldName] || + subschemaConfig.merge[typeName].keyFieldNames.includes(intFieldName) + ) { + interfaceFields[int.name] = interfaceFields[int.name] || {}; + interfaceFields[int.name][intFieldName] = true; + } } } } @@ -168,33 +342,35 @@ function filterIsolatedSubschema(subschemaConfig: IsolatedSubschemaInput): Subsc const filteredSchema = pruneSchema( filterSchema({ schema: subschemaConfig.schema, - rootFieldFilter: (operation, fieldName) => - operation === 'Query' && rootFields[fieldName] != null, + rootFieldFilter: (operation, fieldName, config) => + operation === 'Query' && + (rootFields[fieldName] != null || computedFieldTypes[getNamedType(config.type).name]), objectFieldFilter: (typeName, fieldName) => subschemaConfig.merge[typeName] == null || - subschemaConfig.merge[typeName].fields?.[fieldName] != null, + subschemaConfig.merge[typeName]?.fields?.[fieldName] != null || + (subschemaConfig.merge[typeName]?.keyFieldNames ?? []).includes(fieldName), interfaceFieldFilter: (typeName, fieldName) => interfaceFields[typeName]?.[fieldName] != null, }), + { skipPruning: typ => computedFieldTypes[typ.name] != null }, ); - const filteredFields: Record> = {}; - for (const typeName in filteredSchema.getTypeMap()) { - const type = filteredSchema.getType(typeName); - if (isObjectType(type) || isInterfaceType(type)) { - filteredFields[typeName] = { __typename: true }; - const fieldMap = type.getFields(); - for (const fieldName in fieldMap) { - filteredFields[typeName][fieldName] = true; - } - } - } + const merge = Object.fromEntries( + // get rid of keyFieldNames again + Object.entries(subschemaConfig.merge).map(([typeName, { keyFieldNames, ...config }]) => [ + typeName, + config, + ]), + ); const filteredSubschema = { ...subschemaConfig, + merge, transforms: (subschemaConfig.transforms ?? []).concat([ - new TransformCompositeFields( - (typeName, fieldName) => (filteredFields[typeName]?.[fieldName] ? undefined : null), - (typeName, fieldName) => (filteredFields[typeName]?.[fieldName] ? undefined : null), + _createCompositeFieldFilter(filteredSchema), + new FilterTypes( // filter out empty types + type => + (!isObjectType(type) && !isInterfaceType(type)) || + Object.keys(type.getFields()).length > 0, ), ]), }; diff --git a/packages/stitch/tests/isolateComputedFieldsTransformer.test.ts b/packages/stitch/tests/isolateComputedFieldsTransformer.test.ts index 7241f60d8cb..b37ee038c1b 100644 --- a/packages/stitch/tests/isolateComputedFieldsTransformer.test.ts +++ b/packages/stitch/tests/isolateComputedFieldsTransformer.test.ts @@ -87,13 +87,7 @@ describe('isolateComputedFieldsTransformer', () => { // pruning does not yet remove unused scalars/enums // expect(computedSubschema.transformedSchema.getType('DeliveryService')).toBeUndefined(); - expect( - Object.keys( - ( - computedSubschema.transformedSchema.getType('Storefront') as GraphQLObjectType - ).getFields(), - ).length, - ).toEqual(0); + expect(computedSubschema.transformedSchema.getType('Storefront')).toBeUndefined(); expect(computedSubschema.transformedSchema.getType('ProductRepresentation')).toBeDefined(); assertSome(baseSubschema.merge); @@ -405,4 +399,136 @@ describe('isolateComputedFieldsTransformer', () => { ).toEqual(['productById', 'productByUpc']); }); }); + + describe('with composite return type', () => { + const testSchema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + scalar AField + + type Query { + _item(input: ItemInput!): Item + _giftOptions(itemId: ID!): GiftOptions + } + + input ItemInput { + id: ID! + aField: AField + } + + type Item { + id: ID! + + giftOptionsList: [GiftOptions] + } + + type GiftOptions { + itemId: ID! + someOptions: [String] + } + `, + }); + + it('return type is unmerged type', () => { + const [baseConfig, computedConfig] = isolateComputedFieldsTransformer({ + schema: testSchema, + merge: { + Item: { + selectionSet: '{ id }', + fieldName: '_item', + fields: { + giftOptionsList: { + selectionSet: '{ aField }', + computed: true, + canonical: true, + }, + }, + }, + }, + }); + + const baseSchema = new Subschema(baseConfig); + const computedSubschema = new Subschema(computedConfig); + + const computedGiftOptionsType = computedSubschema.transformedSchema.getType( + 'GiftOptions', + ) as GraphQLObjectType; + expect(computedGiftOptionsType).toBeDefined(); + + const computedGiftOptionsTypeFields = computedGiftOptionsType.getFields(); + expect(computedGiftOptionsTypeFields['itemId']).toBeDefined(); + expect(computedGiftOptionsTypeFields['someOptions']).toBeDefined(); + + const computedQueryType = computedSubschema.transformedSchema.getType( + 'Query', + ) as GraphQLObjectType; + const computedQueryTypeFields = computedQueryType.getFields(); + expect(computedQueryTypeFields['_item']).toBeDefined(); + expect(computedQueryTypeFields['_giftOptions']).toBeDefined(); + + const baseGiftOptionsType = baseSchema.transformedSchema.getType( + 'GiftOptions', + ) as GraphQLObjectType; + expect(baseGiftOptionsType).toBeUndefined(); + + const baseQueryType = baseSchema.transformedSchema.getType('Query') as GraphQLObjectType; + const baseQueryTypeFields = baseQueryType.getFields(); + expect(baseQueryTypeFields['_item']).toBeDefined(); + expect(baseQueryTypeFields['_giftOptions']).toBeUndefined(); + }); + + it('return type is merged type', () => { + const [baseConfig, computedConfig] = isolateComputedFieldsTransformer({ + schema: testSchema, + merge: { + Item: { + selectionSet: '{ id }', + fieldName: '_item', + fields: { + giftOptionsList: { + selectionSet: '{ aField }', + computed: true, + canonical: true, + }, + }, + }, + GiftOptions: { + selectionSet: '{ itemId }', + fieldName: '_giftOptions', + }, + }, + }); + + const baseSubschema = new Subschema(baseConfig); + const computedSubschema = new Subschema(computedConfig); + + const computedGiftOptionsType = computedSubschema.transformedSchema.getType( + 'GiftOptions', + ) as GraphQLObjectType; + expect(computedGiftOptionsType).toBeDefined(); + + const computedGiftOptionsTypeFields = computedGiftOptionsType.getFields(); + expect(computedGiftOptionsTypeFields['itemId']).toBeDefined(); + expect(computedGiftOptionsTypeFields['someOptions']).toBeUndefined(); + + const computedQueryType = computedSubschema.transformedSchema.getType( + 'Query', + ) as GraphQLObjectType; + const computedQueryTypeFields = computedQueryType.getFields(); + expect(computedQueryTypeFields['_item']).toBeDefined(); + expect(computedQueryTypeFields['_giftOptions']).toBeDefined(); + + const baseGiftOptionsType = baseSubschema.transformedSchema.getType( + 'GiftOptions', + ) as GraphQLObjectType; + expect(baseGiftOptionsType).toBeDefined(); + const baseGiftOptionsTypeFields = baseGiftOptionsType.getFields(); + expect(baseGiftOptionsTypeFields['itemId']).toBeDefined(); + expect(baseGiftOptionsTypeFields['someOptions']).toBeDefined(); + + const baseQueryType = baseSubschema.transformedSchema.getType('Query') as GraphQLObjectType; + const baseQueryTypeFields = baseQueryType.getFields(); + expect(baseQueryTypeFields['_item']).toBeDefined(); + expect(baseQueryTypeFields['_giftOptions']).toBeDefined(); + }); + }); }); diff --git a/packages/stitch/tests/mergeComputedFields.test.ts b/packages/stitch/tests/mergeComputedFields.test.ts index 5b306c7f640..262cf3ab677 100644 --- a/packages/stitch/tests/mergeComputedFields.test.ts +++ b/packages/stitch/tests/mergeComputedFields.test.ts @@ -1,4 +1,4 @@ -import { graphql } from 'graphql'; +import { graphql, GraphQLSchema } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { stitchSchemas } from '@graphql-tools/stitch'; import { assertSome } from '@graphql-tools/utils'; @@ -155,3 +155,918 @@ describe('merge computed fields via config', () => { ]); }); }); + +describe('test merged composite computed fields', () => { + const schemaA = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + interface I { + id: ID! + value: Int! + } + + type T implements I { + id: ID! + value: Int! + } + + type U { + id: ID! + value: Int! + } + + union W = T | U + + type Query { + byId(id: ID!): T + uById(id: ID!): U + } + `, + resolvers: { + T: { + value: (obj: { id: string }) => parseInt(obj.id), + }, + U: { + value: (obj: { id: string }) => parseInt(obj.id), + }, + I: { + __resolveType: () => 'T', + }, + Query: { + byId: (_: never, { id }: { id: string }) => ({ id }), + uById: (_: never, { id }: { id: string }) => ({ id }), + }, + }, + }); + + describe('object-valued computed field', () => { + const schemaB = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + interface I { + id: ID! + value2: Int! + } + + type T implements I { + id: ID! + next: T! + value2: Int! + } + + type U { + id: ID! + value2: Int! + } + + type V { + id: ID! + } + + union W = T | U + + union X = T | V + + input TInput { + id: ID! + value: Int + } + + type Query { + byRepresentation(representation: TInput!): T + uByRepresentation(representation: TInput!): U + } + `, + resolvers: { + T: { + next: (obj: { id: string; value: number }) => ({ id: `${obj.value + 1}` }), + value2: (obj: { id: string }) => parseInt(obj.id), + }, + U: { + value2: (obj: { id: string }) => parseInt(obj.id), + }, + Query: { + byRepresentation: ( + _: never, + { representation: { id, value } }: { representation: { id: string; value?: number } }, + ) => ({ id, value }), + uByRepresentation: ( + _: never, + { representation: { id } }: { representation: { id: string } }, + ) => ({ id }), + }, + }, + }); + + // implementation must be provided per test b/c restoreMocks: true in global config, this is the implementation: + // ({ id, value }: { id: string; value?: number }) => ({ representation: { id, value } }) + const byRepresentationArgs = jest.fn< + { representation: { id: string; value?: number } }, + [{ id: string; value?: number }] + >(); + + const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: schemaA, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byId', + args: ({ id }) => ({ id }), + }, + U: { + selectionSet: '{ id }', + fieldName: 'uById', + args: ({ id }) => ({ id }), + }, + }, + }, + { + schema: schemaB, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byRepresentation', + args: byRepresentationArgs, + fields: { + next: { + selectionSet: '{ value }', + computed: true, + }, + }, + }, + U: { + selectionSet: '{ id }', + fieldName: 'uByRepresentation', + args: ({ id }: { id: string }) => ({ representation: { id } }), + }, + }, + }, + ], + }); + + it('computed field dependencies only used when required', async () => { + byRepresentationArgs.mockImplementation(({ id, value }: { id: string; value?: number }) => ({ + representation: { id, value }, + })); + + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value2 + } + } + `, + }); + assertSome(data); + expect(data).toEqual({ + byId: { + value2: 1, + }, + }); + // check value is not provided + expect(byRepresentationArgs).toHaveBeenCalledTimes(1); + expect(byRepresentationArgs.mock.calls[0][0].value).toBeUndefined(); + }); + + it('selection set available locally', async () => { + byRepresentationArgs.mockImplementation( + // something breaks if the mock function is not wrapped in a plain function ... + ({ id, value }: { id: string; value?: number }) => ({ representation: { id, value } }), + ); + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + value + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + value: 2, + }, + }, + }); + }); + + it('selection set is remote', async () => { + byRepresentationArgs.mockImplementation( + // something breaks if the mock function is not wrapped in a plain function ... + ({ id, value }: { id: string; value?: number }) => ({ representation: { id, value } }), + ); + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + value + next { + value + } + } + } + } + `, + }); + + assertSome(data); + + expect(data).toEqual({ + byId: { + value: 1, + next: { + value: 2, + next: { + value: 3, + }, + }, + }, + }); + }); + }); + + describe('interface-valued computed field', () => { + const schemaB = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + interface I { + id: ID! + next: I! + } + + type T implements I { + id: ID! + next: I! + } + + input TInput { + id: ID! + value: Int + } + + type Query { + byRepresentation(representation: TInput!): T + } + `, + resolvers: { + T: { + next: (obj: { id: string; value: number }) => ({ id: `${obj.value + 1}` }), + }, + I: { + __resolveType: () => 'T', + }, + Query: { + byRepresentation: ( + _: never, + { representation: { id, value } }: { representation: { id: string; value?: number } }, + ) => ({ id, value }), + }, + }, + }); + + const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: schemaA, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byId', + args: ({ id }) => ({ id }), + }, + }, + }, + { + schema: schemaB, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byRepresentation', + args: ({ id, value }: { id: string; value?: number }) => ({ + representation: { id, value }, + }), + fields: { + next: { + selectionSet: '{ value }', + computed: true, + }, + }, + }, + }, + }, + ], + }); + + it('selection set available locally', async () => { + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + id + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + id: '2', + }, + }, + }); + }); + + it('selection set is remote', async () => { + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + value + next { + id + } + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + value: 2, + next: { + id: '3', + }, + }, + }, + }); + }); + }); + + describe('union-valued computed field', () => { + const schemaB = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type T { + id: ID! + next: W! + } + + type U { + id: ID! + } + + type V { + id: ID! + } + + union W = T | U | V + + input TInput { + id: ID! + value: Int + } + + type Query { + byRepresentation(representation: TInput!): T + } + `, + resolvers: { + T: { + next: (obj: { id: string; value: number }) => ({ id: `${obj.value + 1}` }), + }, + W: { + __resolveType: () => 'T', + }, + Query: { + byRepresentation: ( + _: never, + { representation: { id, value } }: { representation: { id: string; value?: number } }, + ) => ({ id, value }), + }, + }, + }); + + const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: schemaA, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byId', + args: ({ id }) => ({ id }), + }, + }, + }, + { + schema: schemaB, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byRepresentation', + args: ({ id, value }: { id: string; value?: number }) => ({ + representation: { id, value }, + }), + fields: { + next: { + selectionSet: '{ value }', + computed: true, + }, + }, + }, + }, + }, + ], + }); + + it('selection set available locally', async () => { + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + ... on T { + id + } + ... on U { + id + } + ... on V { + id + } + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + id: '2', + }, + }, + }); + }); + + it('selection set is remote', async () => { + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + ... on T { + value + next { + ... on T { + id + } + ... on U { + id + } + ... on V { + id + } + } + } + ... on U { + id + } + ... on V { + id + } + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + value: 2, + next: { + id: '3', + }, + }, + }, + }); + }); + }); +}); + +describe('test unmerged composite computed fields', () => { + const schemaA = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type T { + id: ID! + value: Int! + } + + type Query { + byId(id: ID!): T + } + `, + resolvers: { + T: { + value: (obj: { id: string }) => parseInt(obj.id), + }, + Query: { + byId: (_: never, { id }: { id: string }) => ({ id }), + }, + }, + }); + + const createGatewaySchema = (schemaB: GraphQLSchema) => + stitchSchemas({ + subschemas: [ + { + schema: schemaA, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byId', + args: ({ id }) => ({ id }), + }, + }, + }, + { + schema: schemaB, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byRepresentation', + args: ({ id, value }: { id: string; value?: number }) => ({ + representation: { id, value }, + }), + fields: { + next: { + selectionSet: '{ value }', + computed: true, + }, + }, + }, + }, + }, + ], + }); + + it('object-valued computed field', async () => { + const schemaB = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type T { + id: ID! + next: U! + } + + type U { + id: ID! + } + + input TInput { + id: ID! + value: Int + } + + type Query { + byRepresentation(representation: TInput!): T + } + `, + resolvers: { + T: { + next: (obj: { id: string; value: number }) => ({ id: `${obj.value + 1}` }), + }, + Query: { + byRepresentation: ( + _: never, + { representation: { id, value } }: { representation: { id: string; value?: number } }, + ) => ({ id, value }), + }, + }, + }); + + const gatewaySchema = createGatewaySchema(schemaB); + + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + id + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + id: '2', + }, + }, + }); + }); + + it('interface-valued computed field', async () => { + const schemaB = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type T { + id: ID! + next: Node! + } + + interface Node { + id: ID! + } + + type U implements Node { + id: ID! + } + + input TInput { + id: ID! + value: Int + } + + type Query { + byRepresentation(representation: TInput!): T + } + `, + resolvers: { + Node: { + __resolveType: () => 'U', + }, + T: { + next: (obj: { id: string; value: number }) => ({ id: `${obj.value + 1}` }), + }, + Query: { + byRepresentation: ( + _: never, + { representation: { id, value } }: { representation: { id: string; value?: number } }, + ) => ({ id, value }), + }, + }, + }); + + const gatewaySchema = createGatewaySchema(schemaB); + + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + id + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + id: '2', + }, + }, + }); + }); + + it('union-valued computed field', async () => { + const schemaB = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type T { + id: ID! + next: W! + } + + type U { + id: ID! + } + + type V { + id: ID! + } + + union W = U | V + + input TInput { + id: ID! + value: Int + } + + type Query { + byRepresentation(representation: TInput!): T + } + `, + resolvers: { + T: { + next: (obj: { id: string; value: number }) => ({ id: `${obj.value + 1}` }), + }, + W: { + __resolveType: () => 'U', + }, + Query: { + byRepresentation: ( + _: never, + { representation: { id, value } }: { representation: { id: string; value?: number } }, + ) => ({ id, value }), + }, + }, + }); + + const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: schemaA, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byId', + args: ({ id }) => ({ id }), + }, + }, + }, + { + schema: schemaB, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byRepresentation', + args: ({ id, value }: { id: string; value?: number }) => ({ + representation: { id, value }, + }), + fields: { + next: { + selectionSet: '{ value }', + computed: true, + }, + }, + }, + }, + }, + ], + }); + + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next { + ... on U { + id + } + ... on V { + id + } + } + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: { + id: '2', + }, + }, + }); + }); + + it('enum-valued computed field', async () => { + const schemaB = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type T { + id: ID! + next: U! + } + + enum U { + ZERO + ONE + TWO + THREE + } + + input TInput { + id: ID! + value: Int + } + + type Query { + byRepresentation(representation: TInput!): T + } + `, + resolvers: { + T: { + next: (obj: { id: string; value: number }) => ['ZERO', 'ONE', 'TWO', 'THREE'][obj.value], + }, + Query: { + byRepresentation: ( + _: never, + { representation: { id, value } }: { representation: { id: string; value?: number } }, + ) => ({ id, value }), + }, + }, + }); + + const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: schemaA, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byId', + args: ({ id }) => ({ id }), + }, + }, + }, + { + schema: schemaB, + merge: { + T: { + selectionSet: '{ id }', + fieldName: 'byRepresentation', + args: ({ id, value }: { id: string; value?: number }) => ({ + representation: { id, value }, + }), + fields: { + next: { + selectionSet: '{ value }', + computed: true, + }, + }, + }, + }, + }, + ], + }); + + const { data } = await graphql({ + schema: gatewaySchema, + source: /* GraphQL */ ` + query { + byId(id: "1") { + value + next + } + } + `, + }); + + assertSome(data); + expect(data).toEqual({ + byId: { + value: 1, + next: 'ONE', + }, + }); + }); +}); diff --git a/packages/stitching-directives/tests/reproductions.test.ts b/packages/stitching-directives/tests/reproductions.test.ts new file mode 100644 index 00000000000..8ccc399c034 --- /dev/null +++ b/packages/stitching-directives/tests/reproductions.test.ts @@ -0,0 +1,60 @@ +import { buildSchema, GraphQLObjectType } from 'graphql'; +import { stitchSchemas } from '@graphql-tools/stitch'; +import { stitchingDirectives } from '../src'; + +describe('Reproductions for issues', () => { + it('issue #4554', () => { + const { allStitchingDirectivesTypeDefs, stitchingDirectivesTransformer } = + stitchingDirectives(); + const schema1 = buildSchema(/* GraphQL */ ` + ${allStitchingDirectivesTypeDefs} + scalar ItemId + scalar ItemId2 + scalar AField + + type Query { + item(itemId: ItemId!, itemId2: ItemId2!): Item! + } + type Item @key(selectionSet: "{ itemId itemId2 }") { + itemId: ItemId! + itemId2: ItemId2! + aField: AField + } + `); + + const schema2 = buildSchema(/* GraphQL */ ` + ${allStitchingDirectivesTypeDefs} + scalar ItemId + scalar ItemId2 + scalar AField + + type Query { + _item(input: ItemInput!): Item + } + + input ItemInput { + itemId: ItemId! + itemId2: ItemId2! + aField: AField + } + + type Item @key(selectionSet: "{ itemId itemId2 }") { + itemId: ItemId! + itemId2: ItemId2! + + giftOptionsList: [GiftOptions] @computed(selectionSet: "{ itemId aField }") + } + + type GiftOptions { + someOptions: [String] + } + `); + const stitchedSchema = stitchSchemas({ + subschemas: [schema1, schema2], + subschemaConfigTransforms: [stitchingDirectivesTransformer], + }); + const giftOptionsType = stitchedSchema.getType('GiftOptions') as GraphQLObjectType; + const giftOptionsTypeFields = giftOptionsType.getFields(); + expect(giftOptionsTypeFields['someOptions']).toBeDefined(); + }); +});