diff --git a/.changeset/federation-external-fields-to-pick.md b/.changeset/federation-external-fields-to-pick.md new file mode 100644 index 00000000..14627fdd --- /dev/null +++ b/.changeset/federation-external-fields-to-pick.md @@ -0,0 +1,9 @@ +--- +'@eddeee888/gcg-typescript-resolver-files': patch +--- + +Fix Federation `@external` fields being picked in object type resolvers when `extend type` is used across multiple modules + +Previously, every field of an object type was added to each module's `Pick<...>`, including `@external` fields that `typescript-resolvers` does not generate resolver types for. With `useIndexSignature: true`, this made the external fields required in the resolver object; without it, the `Pick` keys did not exist on the resolvers type. Both resulted in type errors. + +Fields to pick are now filtered using the same `ApolloFederation` logic as `typescript-resolvers` when `typesPluginsConfig.federation` is enabled i.e. `@external` fields (or fields of an `@external` object type) are skipped, unless there is a `@provides` path to them. diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/codegen.ts b/packages/typescript-resolver-files-e2e/src/test-federation/codegen.ts index 9ece6a04..3a09da60 100644 --- a/packages/typescript-resolver-files-e2e/src/test-federation/codegen.ts +++ b/packages/typescript-resolver-files-e2e/src/test-federation/codegen.ts @@ -35,6 +35,15 @@ const config: CodegenConfig = { }, { schema: `${baseDir}/subgraph-users/**/schema.graphqls` } ), + + [`${baseDir}/subgraph-shipping`]: defineConfig( + { + typeDefsFilePath: false, + mergeSchema: false, + typesPluginsConfig: { federation: true }, + }, + { schema: `${baseDir}/subgraph-shipping/**/schema.graphqls` } + ), }, }; diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/resolvers/Product.ts b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/resolvers/Product.ts new file mode 100644 index 00000000..77a8139b --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/resolvers/Product.ts @@ -0,0 +1,4 @@ +import type { ProductResolvers } from './../../types.generated'; + export const Product: Pick = { + /* Implement Product resolver logic here */ + }; \ No newline at end of file diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/resolvers/Query/cheapestProduct.ts b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/resolvers/Query/cheapestProduct.ts new file mode 100644 index 00000000..f936a5db --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/resolvers/Query/cheapestProduct.ts @@ -0,0 +1,3 @@ + + import type { QueryResolvers } from './../../../types.generated'; + export const cheapestProduct: NonNullable = async (_parent, _arg, _ctx) => { /* Implement Query.cheapestProduct resolver logic here */ }; \ No newline at end of file diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/schema.graphqls b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/schema.graphqls new file mode 100644 index 00000000..65c3a837 --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-1/schema.graphqls @@ -0,0 +1,15 @@ +type Query { + cheapestProduct: Product @provides(fields: "name") +} + +type Product @key(fields: "upc") { + upc: String! + weight: Int @external + shippingEstimate: Int @requires(fields: "weight") +} + +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@requires", "@provides"] + ) diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-2/resolvers/Product.ts b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-2/resolvers/Product.ts new file mode 100644 index 00000000..24a83751 --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-2/resolvers/Product.ts @@ -0,0 +1,4 @@ +import type { ProductResolvers } from './../../types.generated'; + export const Product: Pick = { + /* Implement Product resolver logic here */ + }; \ No newline at end of file diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-2/schema.graphqls b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-2/schema.graphqls new file mode 100644 index 00000000..bc142f2f --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/part-2/schema.graphqls @@ -0,0 +1,5 @@ +extend type Product @key(fields: "upc") { + name: String @external + price: Int @external + tax: Int @requires(fields: "price") +} diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/resolvers.generated.ts b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/resolvers.generated.ts new file mode 100644 index 00000000..1219bbb3 --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/resolvers.generated.ts @@ -0,0 +1,11 @@ +/* This file was automatically generated. DO NOT UPDATE MANUALLY. */ + import type { Resolvers } from './types.generated'; + import { cheapestProduct as Query_cheapestProduct } from './part-1/resolvers/Query/cheapestProduct'; +import { Product as part_2_Product } from './part-2/resolvers/Product'; +import { Product as part_1_Product } from './part-1/resolvers/Product'; + export const resolvers: Resolvers = { + Query: { cheapestProduct: Query_cheapestProduct }, + + + Product: { ...part_2_Product,...part_1_Product } + } \ No newline at end of file diff --git a/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/types.generated.ts b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/types.generated.ts new file mode 100644 index 00000000..4ed8b478 --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-federation/schema-base/subgraph-shipping/types.generated.ts @@ -0,0 +1,159 @@ +import { GraphQLResolveInfo } from 'graphql'; +export type Maybe = T | null | undefined; +export type InputMaybe = T | null | undefined; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + _FieldSet: { input: unknown; output: unknown; } +}; + +export type Product = { + __typename?: 'Product'; + name?: Maybe; + price?: Maybe; + shippingEstimate?: Maybe; + tax?: Maybe; + upc: Scalars['String']['output']; + weight?: Maybe; +}; + +export type Query = { + __typename?: 'Query'; + cheapestProduct?: Maybe; +}; + + + +export type ResolverTypeWrapper = Promise | T; + +export type ReferenceResolver = ( + reference: TReference, + context: TContext, + info: GraphQLResolveInfo + ) => Promise | TResult; + + type ScalarCheck = S extends true ? T : NullableCheck; + type NullableCheck = Maybe extends T ? Maybe, S>> : ListCheck; + type ListCheck = T extends (infer U)[] ? NullableCheck[] : GraphQLRecursivePick; + export type GraphQLRecursivePick = { [K in keyof T & keyof S]: ScalarCheck }; + + +export type ResolverWithResolve = { + resolve: ResolverFn; +}; +export type Resolver, TContext = Record, TArgs = Record> = ResolverFn | ResolverWithResolve; + +export type ResolverFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => Promise | TResult; + +export type SubscriptionSubscribeFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => AsyncIterable | Promise>; + +export type SubscriptionResolveFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => TResult | Promise; + +export interface SubscriptionSubscriberObject { + subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs>; + resolve?: SubscriptionResolveFn; +} + +export interface SubscriptionResolverObject { + subscribe: SubscriptionSubscribeFn; + resolve: SubscriptionResolveFn; +} + +export type SubscriptionObject = + | SubscriptionSubscriberObject + | SubscriptionResolverObject; + +export type SubscriptionResolver, TContext = Record, TArgs = Record> = + | ((...args: any[]) => SubscriptionObject) + | SubscriptionObject; + +export type TypeResolveFn, TContext = Record> = ( + parent: TParent, + context: TContext, + info: GraphQLResolveInfo +) => Maybe | Promise>; + +export type IsTypeOfResolverFn, TContext = Record> = (obj: T, context: TContext, info: GraphQLResolveInfo) => boolean | Promise; + +export type NextResolverFn = () => Promise; + +export type DirectiveResolverFn, TParent = Record, TContext = Record, TArgs = Record> = ( + next: NextResolverFn, + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => TResult | Promise; + +/** Mapping of federation types */ +export type FederationTypes = { + Product: Product; +}; + +/** Mapping of federation reference types */ +export type FederationReferenceTypes = { + Product: + ( { __typename: 'Product' } + & GraphQLRecursivePick + & ( Record + | GraphQLRecursivePick + | GraphQLRecursivePick + | GraphQLRecursivePick ) ); +}; + + + +/** Mapping between all available schema types and the resolvers types */ +export type ResolversTypes = { + Product: ResolverTypeWrapper; + String: ResolverTypeWrapper; + Int: ResolverTypeWrapper; + Query: ResolverTypeWrapper>; + Boolean: ResolverTypeWrapper; +}; + +/** Mapping between all available schema types and the resolvers parents */ +export type ResolversParentTypes = { + Product: Product | FederationReferenceTypes['Product']; + String: Scalars['String']['output']; + Int: Scalars['Int']['output']; + Query: Record; + Boolean: Scalars['Boolean']['output']; +}; + +export type ProductResolvers = { + __resolveReference?: ReferenceResolver | FederationReferenceType, FederationReferenceType, ContextType>; + name?: Resolver, ParentType, ContextType>; + shippingEstimate?: Resolver, ParentType, ContextType>; + tax?: Resolver, ParentType, ContextType>; + upc?: Resolver; +}; + +export type QueryResolvers = { + cheapestProduct?: Resolver, ParentType, ContextType>; +}; + +export type Resolvers = { + Product?: ProductResolvers; + Query?: QueryResolvers; +}; + diff --git a/packages/typescript-resolver-files/src/parseGraphQLSchema/findFieldNamesToGenerate.spec.ts b/packages/typescript-resolver-files/src/parseGraphQLSchema/findFieldNamesToGenerate.spec.ts new file mode 100644 index 00000000..0ab5b444 --- /dev/null +++ b/packages/typescript-resolver-files/src/parseGraphQLSchema/findFieldNamesToGenerate.spec.ts @@ -0,0 +1,68 @@ +import { buildSchema, type GraphQLObjectType } from 'graphql'; +import { ApolloFederation } from '@graphql-codegen/plugin-helpers'; +import { findFieldNamesToGenerate } from './findFieldNamesToGenerate.js'; + +const schema = buildSchema(/* GraphQL */ ` + directive @key(fields: String!) repeatable on OBJECT + directive @external on FIELD_DEFINITION | OBJECT + directive @requires(fields: String!) on FIELD_DEFINITION + directive @provides(fields: String!) on FIELD_DEFINITION + + type Query { + product: Product @provides(fields: "name") + warehouse: Warehouse @provides(fields: "address") + } + + type Product @key(fields: "id") { + id: ID! + } + + extend type Product { + name: String @external + weight: Int @external + shippingEstimate: Int @requires(fields: "weight") + } + + type Warehouse @key(fields: "id") { + id: ID! + } + + extend type Warehouse @external { + address: String + capacity: Int + } +`); + +const getFieldNames = (federation: boolean, typeName: string): string[] => [ + ...findFieldNamesToGenerate({ + namedType: schema.getType(typeName) as GraphQLObjectType, + apolloFederation: new ApolloFederation({ + enabled: federation, + schema, + meta: {}, + }), + }), +]; + +describe('findFieldNamesToGenerate', () => { + it('returns all fields if federation is disabled', () => { + expect(getFieldNames(false, 'Product')).toEqual([ + 'id', + 'name', + 'weight', + 'shippingEstimate', + ]); + }); + + it('skips `@external` fields without `@provides` path if federation is enabled', () => { + expect(getFieldNames(true, 'Product')).toEqual([ + 'id', + 'name', + 'shippingEstimate', + ]); + }); + + it('returns only fields with `@provides` path if object type extension is `@external`', () => { + expect(getFieldNames(true, 'Warehouse')).toEqual(['address']); + }); +}); diff --git a/packages/typescript-resolver-files/src/parseGraphQLSchema/findFieldNamesToGenerate.ts b/packages/typescript-resolver-files/src/parseGraphQLSchema/findFieldNamesToGenerate.ts new file mode 100644 index 00000000..50982471 --- /dev/null +++ b/packages/typescript-resolver-files/src/parseGraphQLSchema/findFieldNamesToGenerate.ts @@ -0,0 +1,40 @@ +import type { + FieldDefinitionNode, + GraphQLObjectType, + ObjectTypeDefinitionNode, +} from 'graphql'; +import type { ApolloFederation } from '@graphql-codegen/plugin-helpers'; + +/** + * findFieldNamesToGenerate + * + * Finds the names of an object type's fields that `typescript-resolvers` generates resolver types for, + * using the same `ApolloFederation` logic as `typescript-resolvers` so the two never disagree. + * e.g. In Federation, `@external` fields (or fields of an `@external` object type) are skipped unless there is a `@provides` path to them. + */ +export const findFieldNamesToGenerate = ({ + namedType, + apolloFederation, +}: { + namedType: GraphQLObjectType; + apolloFederation: ApolloFederation; +}): Set => { + // `findFieldNodesToGenerate` is called by `typescript-resolvers` with visitor-transformed nodes, + // where each field is wrapped as `{ node: FieldDefinitionNode }`. We mimic that shape here. + // Directives are gathered from the definition and every extension, so `@external` on any of them is detected. + const node = { + name: { value: namedType.name }, + directives: [namedType.astNode, ...namedType.extensionASTNodes].flatMap( + (typeNode) => typeNode?.directives || [] + ), + fields: Object.values(namedType.getFields()).map((field) => ({ + node: field.astNode || { name: { value: field.name } }, + })), + } as unknown as ObjectTypeDefinitionNode; + + return new Set( + apolloFederation + .findFieldNodesToGenerate({ node }) + .map((fieldNode: FieldDefinitionNode) => fieldNode.name.value) + ); +}; diff --git a/packages/typescript-resolver-files/src/parseGraphQLSchema/parseGraphQLSchema.ts b/packages/typescript-resolver-files/src/parseGraphQLSchema/parseGraphQLSchema.ts index d1a034b9..5dfa3bd4 100644 --- a/packages/typescript-resolver-files/src/parseGraphQLSchema/parseGraphQLSchema.ts +++ b/packages/typescript-resolver-files/src/parseGraphQLSchema/parseGraphQLSchema.ts @@ -13,6 +13,7 @@ import { isInterfaceType, isEnumType, } from 'graphql'; +import { ApolloFederation } from '@graphql-codegen/plugin-helpers'; import type { ParseSourcesResult } from '../parseSources/index.js'; import type { TypeMappersMap } from '../parseTypeMappers/index.js'; import type { @@ -28,6 +29,7 @@ import { } from '../utils/index.js'; import { parseLocationForOutputDir } from './parseLocationForOutputDir.js'; import { normalizeResolverName } from './normalizeResolverName.js'; +import { findFieldNamesToGenerate } from './findFieldNamesToGenerate.js'; import type { GeneratedTypesFileMeta } from '../generateResolverFiles/index.js'; interface ParseGraphQLSchemaParams { @@ -42,6 +44,7 @@ interface ParseGraphQLSchemaParams { resolverRelativeTargetDir: string; whitelistedModules: ParsedPresetConfig['whitelistedModules']; blacklistedModules: ParsedPresetConfig['blacklistedModules']; + federation: boolean; } export interface ResolverDetails { @@ -113,10 +116,16 @@ export const parseGraphQLSchema = async ({ resolverRelativeTargetDir, whitelistedModules, blacklistedModules, + federation, }: ParseGraphQLSchemaParams): Promise => { const scalarsModuleResolverMap = scalarsModule ? await getScalarResolverMapFromModule(scalarsModule) : {}; + const apolloFederation = new ApolloFederation({ + enabled: federation, + schema: schemaAst, + meta: {}, + }); return Object.entries(schemaAst.getTypeMap()).reduce( (res, [schemaType, namedType]) => { @@ -187,6 +196,7 @@ export const parseGraphQLSchema = async ({ namedType, schemaType, result: res, + apolloFederation, }); return res; } @@ -358,6 +368,7 @@ const handleObjectType = ({ namedType, schemaType, result, + apolloFederation, }: { mode: ParseGraphQLSchemaParams['mode']; sourceMap: ParseGraphQLSchemaParams['sourceMap']; @@ -369,7 +380,13 @@ const handleObjectType = ({ namedType: GraphQLObjectType; schemaType: string; result: ParsedGraphQLSchemaMeta; + apolloFederation: ApolloFederation; }): void => { + const fieldNamesToGenerate = findFieldNamesToGenerate({ + namedType, + apolloFederation, + }); + // parse for details const fieldsByGraphQLModule = Object.entries(namedType.getFields()).reduce< Record< @@ -379,7 +396,13 @@ const handleObjectType = ({ firstFieldLocation: Location | undefined; } > - >((res, [_, fieldNode]) => { + >((res, [fieldName, fieldNode]) => { + // Fields without generated resolver types must not be picked + // e.g. Federation `@external` fields without a `@provides` path + if (!fieldNamesToGenerate.has(fieldName)) { + return res; + } + const fieldLocation = fieldNode.astNode?.loc; const modulePath = path.dirname(fieldLocation?.source.name || ''); diff --git a/packages/typescript-resolver-files/src/preset.ts b/packages/typescript-resolver-files/src/preset.ts index 57ec0b8c..87b03256 100644 --- a/packages/typescript-resolver-files/src/preset.ts +++ b/packages/typescript-resolver-files/src/preset.ts @@ -134,6 +134,7 @@ export const preset: Types.OutputPreset = { resolverRelativeTargetDir, whitelistedModules, blacklistedModules, + federation: Boolean(typesPluginsConfig.federation), }), createProfilerRunName('parseGraphQLSchema') ); diff --git a/packages/typescript-resolver-files/vite.config.ts b/packages/typescript-resolver-files/vite.config.ts index 4c54ff5f..57ac3c9e 100644 --- a/packages/typescript-resolver-files/vite.config.ts +++ b/packages/typescript-resolver-files/vite.config.ts @@ -15,6 +15,8 @@ export default defineConfig(() => ({ globals: true, environment: 'node', include: ['src/**/*.spec.ts'], + // Process `@graphql-codegen/plugin-helpers` with Vite so it resolves the same `graphql` instance as specs + server: { deps: { inline: ['@graphql-codegen/plugin-helpers'] } }, reporters: ['default'], coverage: { reportsDirectory: '../../coverage/packages/typescript-resolver-files',