From 7f99817b84bbe7473ce85e21dae6ff92fd3a3198 Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Mon, 1 Dec 2025 20:41:44 -0500 Subject: [PATCH 1/2] input types, input/output enums are generated to the target files --- dev-test/star-wars/types.avoidOptionals.ts | 10 ++ dev-test/star-wars/types.excludeQueryAlpha.ts | 10 ++ dev-test/star-wars/types.excludeQueryBeta.ts | 10 ++ .../star-wars/types.globallyAvailable.d.ts | 10 ++ dev-test/star-wars/types.immutableTypes.ts | 10 ++ ...ypes.preResolveTypes.onlyOperationTypes.ts | 10 ++ dev-test/star-wars/types.preResolveTypes.ts | 10 ++ dev-test/star-wars/types.skipSchema.ts | 10 ++ dev-test/star-wars/types.ts | 10 ++ .../typescript/operations/src/index.ts | 30 ++-- .../typescript/operations/src/visitor.ts | 142 ++++++++++++++++++ .../tests/extract-all-types.spec.ts | 40 ++++- .../tests/ts-documents.standalone.spec.ts | 14 +- 13 files changed, 291 insertions(+), 25 deletions(-) diff --git a/dev-test/star-wars/types.avoidOptionals.ts b/dev-test/star-wars/types.avoidOptionals.ts index 96a96ea0b4d..be304fc5c1f 100644 --- a/dev-test/star-wars/types.avoidOptionals.ts +++ b/dev-test/star-wars/types.avoidOptionals.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.excludeQueryAlpha.ts b/dev-test/star-wars/types.excludeQueryAlpha.ts index 7ccbc0a787c..540f85e5c25 100644 --- a/dev-test/star-wars/types.excludeQueryAlpha.ts +++ b/dev-test/star-wars/types.excludeQueryAlpha.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.excludeQueryBeta.ts b/dev-test/star-wars/types.excludeQueryBeta.ts index 9f20261977e..9233ecfac9f 100644 --- a/dev-test/star-wars/types.excludeQueryBeta.ts +++ b/dev-test/star-wars/types.excludeQueryBeta.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.globallyAvailable.d.ts b/dev-test/star-wars/types.globallyAvailable.d.ts index 0e376af86bc..3e569d17f5b 100644 --- a/dev-test/star-wars/types.globallyAvailable.d.ts +++ b/dev-test/star-wars/types.globallyAvailable.d.ts @@ -247,6 +247,16 @@ type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.immutableTypes.ts b/dev-test/star-wars/types.immutableTypes.ts index bb485fff875..bea03b64839 100644 --- a/dev-test/star-wars/types.immutableTypes.ts +++ b/dev-test/star-wars/types.immutableTypes.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts index 9597c939d8f..c503da95b02 100644 --- a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts @@ -58,6 +58,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.preResolveTypes.ts b/dev-test/star-wars/types.preResolveTypes.ts index 99c11f7e757..42cd01885f4 100644 --- a/dev-test/star-wars/types.preResolveTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.skipSchema.ts b/dev-test/star-wars/types.skipSchema.ts index 99c11f7e757..42cd01885f4 100644 --- a/dev-test/star-wars/types.skipSchema.ts +++ b/dev-test/star-wars/types.skipSchema.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/dev-test/star-wars/types.ts b/dev-test/star-wars/types.ts index 99c11f7e757..42cd01885f4 100644 --- a/dev-test/star-wars/types.ts +++ b/dev-test/star-wars/types.ts @@ -249,6 +249,16 @@ export type Episode = /** Star Wars Episode IV: A New Hope, released in 1977. */ | 'NEWHOPE'; +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary: string; + /** Favorite color, optional */ + favoriteColor: ColorInput; + /** 0-5 stars */ + stars: number; +}; + export type CreateReviewForEpisodeMutationVariables = Exact<{ episode: Episode; review: ReviewInput; diff --git a/packages/plugins/typescript/operations/src/index.ts b/packages/plugins/typescript/operations/src/index.ts index 8dd2c1b3439..82a6ed6d94d 100644 --- a/packages/plugins/typescript/operations/src/index.ts +++ b/packages/plugins/typescript/operations/src/index.ts @@ -25,25 +25,14 @@ export const plugin: PluginFunction typeof def === 'string').join('\n'); + const schemaTypesDefinitions = schemaTypes.definitions.filter(def => typeof def === 'string'); + + let content = [...schemaTypesDefinitions, ...operationsDefinitions].join('\n'); - const content: string[] = []; - if (schemaTypesContent) { - content.push(schemaTypesContent); + if (config.globalNamespace) { + content = ` + declare global { + ${content} + }`; } - content.push(operationsContent); return { prepend: [ @@ -66,7 +58,7 @@ export const plugin: PluginFunction = { [K in keyof T]: T[K] };', ], - content: content.join('\n'), + content, }; }; diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 3e4ae45692b..f2ad7853da9 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -2,9 +2,12 @@ import { BaseDocumentsVisitor, type ConvertSchemaEnumToDeclarationBlockString, convertSchemaEnumToDeclarationBlockString, + DeclarationBlock, DeclarationKind, generateFragmentImportStatement, getConfigValue, + indent, + isOneOfInputObjectType, LoadedFragment, normalizeAvoidOptionals, NormalizedAvoidOptionalsConfig, @@ -14,6 +17,7 @@ import { PreResolveTypesProcessor, SelectionSetProcessorConfig, SelectionSetToObject, + transformComment, wrapTypeWithModifiers, } from '@graphql-codegen/visitor-plugin-common'; import autoBind from 'auto-bind'; @@ -21,6 +25,7 @@ import { type DocumentNode, EnumTypeDefinitionNode, type FragmentDefinitionNode, + getNamedType, GraphQLEnumType, GraphQLInputObjectType, type GraphQLNamedInputType, @@ -28,10 +33,17 @@ import { type GraphQLOutputType, GraphQLScalarType, type GraphQLSchema, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, isEnumType, isNonNullType, Kind, + ListTypeNode, + NamedTypeNode, + NonNullTypeNode, + TypeInfo, visit, + visitWithTypeInfo, } from 'graphql'; import { TypeScriptDocumentsPluginConfig } from './config.js'; import { TypeScriptOperationVariablesToObject } from './ts-operation-variables-to-object.js'; @@ -168,6 +180,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< ); this._declarationBlockConfig = { ignoreExport: this.config.noExport, + enumNameValueSeparator: ' =', }; } @@ -196,6 +209,110 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< }); } + InputObjectTypeDefinition(node: InputObjectTypeDefinitionNode): string | null { + const inputTypeName = node.name.value; + if (!this._usedNamedInputTypes[inputTypeName]) { + return null; + } + + if (isOneOfInputObjectType(this._schema.getType(inputTypeName))) { + return this.getInputObjectOneOfDeclarationBlock(node).string; + } + + return this.getInputObjectDeclarationBlock(node).string; + } + + private getInputObjectDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock { + return new DeclarationBlock(this._declarationBlockConfig) + .export() + .asKind('type') + .withName(this.convertName(node)) + .withComment(node.description?.value) + .withBlock((node.fields || []).join('\n')); + } + + private getInputObjectOneOfDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock { + const declarationKind = (node.fields?.length || 0) === 1 ? 'type' : 'type'; + return new DeclarationBlock(this._declarationBlockConfig) + .export() + .asKind(declarationKind) + .withName(this.convertName(node)) + .withComment(node.description?.value) + .withContent(`\n` + (node.fields || []).join('\n |')); + } + + private isValidVisitor(ancestors: any): boolean { + const currentVisitContext = this.getVisitorKindContextFromAncestors(ancestors); + const isVisitingInputType = currentVisitContext.includes(Kind.INPUT_OBJECT_TYPE_DEFINITION); + const isVisitingEnumType = currentVisitContext.includes(Kind.ENUM_TYPE_DEFINITION); + const isVisitingOperation = currentVisitContext.includes(Kind.OPERATION_DEFINITION); + + if (isVisitingOperation) { + return false; + } + + if (!isVisitingInputType && !isVisitingEnumType) { + return false; + } + + return true; + } + + InputValueDefinition(node: InputValueDefinitionNode): string { + const comment = transformComment(node.description?.value || '', 1); + const type: string = node.type as any as string; + return comment + indent(`${node.name.value}: ${type};`); + } + + NamedType(node: NamedTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { + if (!this.isValidVisitor(ancestors)) { + return undefined; + } + + const schemaType = this._schema.getType(node.name.value); + + // For scalars, use the configured scalar type (use input property for input context) + if (schemaType instanceof GraphQLScalarType) { + const scalarConfig = this.scalars[node.name.value]; + if (scalarConfig && 'input' in scalarConfig) { + // scalarConfig.input is already the type string (extracted from ParsedMapper in BaseVisitor) + const inputType = scalarConfig.input; + // If the type is 'any', use the scalar name itself instead (for custom scalars) + if (inputType === 'any') { + return node.name.value; + } + return inputType; + } + // Fallback to scalar name + return node.name.value; + } + + // For enums and input types, use the converted name + if (schemaType instanceof GraphQLEnumType || schemaType instanceof GraphQLInputObjectType) { + return this.convertName(node.name.value); + } + + return node.name.value; + } + + ListType(node: ListTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { + if (!this.isValidVisitor(ancestors)) { + return undefined; + } + + const asString = node.type as any as string; + const listModifier = this.config.immutableTypes ? 'ReadonlyArray' : 'Array'; + return `${listModifier}<${asString}>`; + } + + NonNullType(node: NonNullTypeNode, _key: any, _parent: any, _path: any, ancestors: any): string | undefined { + if (!this.isValidVisitor(ancestors)) { + return undefined; + } + + return node.type as any as string; + } + public getImports(): Array { return !this.config.globalNamespace && (this.config.inlineFragmentTypes === 'combine' || this.config.inlineFragmentTypes === 'mask') @@ -225,6 +342,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const usedInputTypes: UsedNamedInputTypes = {}; + // First collect types from variable definitions visit(documentNode, { VariableDefinition: variableDefinitionNode => { visit(variableDefinitionNode, { @@ -243,6 +361,30 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< }, }); + // Only collect enums from output types when not using namespacedImportName + // When namespacedImportName is set, enums should come from the types package + if (!this.config.namespacedImportName) { + const typeInfo = new TypeInfo(schema); + + visit( + documentNode, + visitWithTypeInfo(typeInfo, { + Field: () => { + // Get the type of the current field + const fieldType = typeInfo.getType(); + if (fieldType) { + const namedType = getNamedType(fieldType); + + // If it's an enum, add it + if (namedType instanceof GraphQLEnumType) { + usedInputTypes[namedType.name] = namedType; + } + } + }, + }) + ); + } + return usedInputTypes; } } diff --git a/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts b/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts index c64468cf85c..d24fe00a07c 100644 --- a/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts +++ b/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts @@ -401,7 +401,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; export type ConversationBotSolutionFragment_BotSolution_originatedFrom_EmailInteraction = { __typename: 'EmailInteraction', originalEmailURLPath: string }; @@ -565,7 +571,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( { id: string, htmlUrl: string, title: string, url: string } & { __typename: 'ArchivedArticle' } ); @@ -734,7 +746,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; export type ConversationBotSolutionFragment_BotSolution_originatedFrom_EmailInteraction = ( { __typename: 'EmailInteraction' } @@ -972,7 +990,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = { __typename: 'ArchivedArticle', id: string, htmlUrl: string, title: string, url: string }; export type ConversationBotSolutionFragment_BotSolution_originatedFrom_EmailInteraction = ( { __typename: 'EmailInteraction' } @@ -1207,7 +1231,13 @@ describe('extractAllFieldsToTypes: true', () => { { outputFile: '' } ); expect(content).toMatchInlineSnapshot(` - "export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( + "export type CallType = + | 'OUTGOING' + | 'INCOMING' + | 'VOICEMAIL' + | 'UNKNOWN'; + + export type ConversationBotSolutionFragment_BotSolution_article_ArchivedArticle = ( { __typename: 'ArchivedArticle' } & Pick< ArchivedArticle, diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index 06dd1752443..a6f15184d80 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -84,14 +84,26 @@ describe('TypeScript Operations Plugin - Standalone', () => { } `); - const result = mergeOutputs([await plugin(schema, [{ document }], {})]); + const result = mergeOutputs([await plugin(schema, [{ document }], {})]); // enumType: 'string-literal' expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; + export type ResponseErrorType = + | 'NOT_FOUND' + | 'INPUT_VALIDATION_ERROR' + | 'FORBIDDEN_ERROR' + | 'UNEXPECTED_ERROR'; + export type UserRole = | 'ADMIN' | 'CUSTOMER'; + export type UsersInput = { + from: DateTime; + to: DateTime; + role: UserRole; + }; + export type UserQueryVariables = Exact<{ id: string; }>; From aa5e43a981bf65329782dfc0f07c29a27331bd5f Mon Sep 17 00:00:00 2001 From: Igor Kusakov Date: Mon, 1 Dec 2025 21:10:03 -0500 Subject: [PATCH 2/2] cleanup --- .../typescript/operations/tests/ts-documents.standalone.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts index a6f15184d80..b50688c3f43 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -84,7 +84,7 @@ describe('TypeScript Operations Plugin - Standalone', () => { } `); - const result = mergeOutputs([await plugin(schema, [{ document }], {})]); // enumType: 'string-literal' + const result = mergeOutputs([await plugin(schema, [{ document }], {})]); expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] };