diff --git a/.changeset/every-queens-sin.md b/.changeset/every-queens-sin.md new file mode 100644 index 00000000000..cda139690b5 --- /dev/null +++ b/.changeset/every-queens-sin.md @@ -0,0 +1,6 @@ +--- +'@graphql-codegen/typescript-operations': major +'@graphql-codegen/client-preset': major +--- + +Conditionally generate input types and output enums into target file diff --git a/dev-test/star-wars/types.avoidOptionals.ts b/dev-test/star-wars/types.avoidOptionals.ts index 7bbb8f54163..d8d66eb85f0 100644 --- a/dev-test/star-wars/types.avoidOptionals.ts +++ b/dev-test/star-wars/types.avoidOptionals.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 24539db56b3..bd0404dfccb 100644 --- a/dev-test/star-wars/types.excludeQueryAlpha.ts +++ b/dev-test/star-wars/types.excludeQueryAlpha.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 1054aac1314..7837d3dca5c 100644 --- a/dev-test/star-wars/types.excludeQueryBeta.ts +++ b/dev-test/star-wars/types.excludeQueryBeta.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 48a1dc3d9e6..cd8a2dc3b44 100644 --- a/dev-test/star-wars/types.globallyAvailable.d.ts +++ b/dev-test/star-wars/types.globallyAvailable.d.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 961c04bd21c..5040a308076 100644 --- a/dev-test/star-wars/types.immutableTypes.ts +++ b/dev-test/star-wars/types.immutableTypes.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 e458fd3fb51..92e1347870d 100644 --- a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 e458fd3fb51..92e1347870d 100644 --- a/dev-test/star-wars/types.preResolveTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 e458fd3fb51..92e1347870d 100644 --- a/dev-test/star-wars/types.skipSchema.ts +++ b/dev-test/star-wars/types.skipSchema.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 e458fd3fb51..92e1347870d 100644 --- a/dev-test/star-wars/types.ts +++ b/dev-test/star-wars/types.ts @@ -1,5 +1,14 @@ type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** The episodes in the Star Wars trilogy */ +export type Episode = + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + | 'EMPIRE' + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + | 'JEDI' + /** Star Wars Episode IV: A New Hope, released in 1977. */ + | 'NEWHOPE'; + 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 95775dae386..cbc79218331 100644 --- a/packages/plugins/typescript/operations/src/index.ts +++ b/packages/plugins/typescript/operations/src/index.ts @@ -26,25 +26,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: [ @@ -70,7 +62,7 @@ export const plugin: PluginFunction; +type UsedNamedInputTypes = Record< + string, + | { type: 'GraphQLScalarType'; node: GraphQLScalarType; tsType: string } + | { type: 'GraphQLEnumType'; node: GraphQLEnumType; tsType: string } + | { type: 'GraphQLInputObjectType'; node: GraphQLInputObjectType; tsType: string } +>; export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< TypeScriptDocumentsPluginConfig, @@ -164,7 +180,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< this.config.enumValues, this.config.arrayInputCoercion, undefined, - 'InputMaybe' + undefined ) ); this._declarationBlockConfig = { @@ -198,6 +214,142 @@ 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 new DeclarationBlock(this._declarationBlockConfig) + .asKind('type') + .withName(this.convertName(node)) + .withComment(node.description?.value) + .withContent(`\n` + (node.fields || []).join('\n |')).string; + } + + return new DeclarationBlock(this._declarationBlockConfig) + .asKind('type') + .withName(this.convertName(node)) + .withComment(node.description?.value) + .withBlock((node.fields || []).join('\n')).string; + } + + InputValueDefinition( + node: InputValueDefinitionNode, + _key?: number | string, + _parent?: any, + _path?: Array, + ancestors?: Array + ): string { + const oneOfDetails = parseOneOfInputValue({ + node, + schema: this._schema, + ancestors, + }); + + // 1. Flatten GraphQL type nodes to make it easier to turn into string + // GraphQL type nodes may have `NonNullType` type before each `ListType` or `NamedType` + // This make it a bit harder to know whether a `ListType` or `Namedtype` is nullable without looking at the node before it. + // Flattening it into an array where the nullability is in `ListType` and `NamedType` makes it easier to code, + // + // So, we recursively call `collectAndFlattenTypeNodes` to handle the following scenarios: + // - [Thing] + // - [Thing!] + // - [Thing]! + // - [Thing!]! + const typeNodes: Parameters[0]['typeNodes'] = []; + collectAndFlattenTypeNodes({ + currentTypeNode: node.type, + isPreviousNodeNonNullable: oneOfDetails.isOneOfInputValue, // If the InputValue is part of @oneOf input, we treat it as non-null (even if it must be null in the schema) + typeNodes, + }); + + // 2. Generate the type of a TypeScript field declaration + // e.g. `field?: string`, then the `string` is the `typePart` + let typePart: string = ''; + // We call `.reverse()` here to get the base type node first + for (const typeNode of typeNodes.reverse()) { + if (typeNode.type === 'NamedType') { + const usedInputType = this._usedNamedInputTypes[typeNode.name]; + if (!usedInputType) { + continue; + } + + typePart = usedInputType.tsType; // If the schema is correct, when reversing typeNodes, the first node would be `NamedType`, which means we can safely set it as the base for typePart + if (usedInputType.tsType !== 'any' && !typeNode.isNonNullable) { + typePart += ' | null | undefined'; + } + continue; + } + + if (typeNode.type === 'ListType') { + typePart = `Array<${typePart}>`; + if (!typeNode.isNonNullable) { + typePart += ' | null | undefined'; + } + } + } + + // TODO: eddeee888 check if we want to support `directiveArgumentAndInputFieldMappings` for operations + // if (node.directives && this.config.directiveArgumentAndInputFieldMappings) { + // typePart = + // getDirectiveOverrideType({ + // directives: node.directives, + // directiveArgumentAndInputFieldMappings: this.config.directiveArgumentAndInputFieldMappings, + // }) || typePart; + // } + + const addOptionalSign = + !oneOfDetails.isOneOfInputValue && + !this.config.avoidOptionals.inputValue && + (node.type.kind !== Kind.NON_NULL_TYPE || + (!this.config.avoidOptionals.defaultValue && node.defaultValue !== undefined)); + + // 3. Generate the keyPart of the TypeScript field declaration + // e.g. `field?: string`, then the `field?` is the `keyPart` + const keyPart = `${node.name.value}${addOptionalSign ? '?' : ''}`; + + // 4. other parts of TypeScript field declaration + const commentPart = getNodeComment(node); + const readonlyPart = this.config.immutableTypes ? 'readonly ' : ''; + + const currentInputValue = commentPart + indent(`${readonlyPart}${keyPart}: ${typePart};`); + + // 5. Check if field is part of `@oneOf` input type + // If yes, we must generate a union member where the current inputValue must be provieded, and the others are not + // e.g. + // ```graphql + // input UserInput { + // byId: ID + // byEmail: String + // byLegacyId: ID + // } + // ``` + // + // Then, the generated type is: + // ```ts + // type UserInput = + // | { byId: string | number; byEmail?: never; byLegacyId?: never } + // | { byId?: never; byEmail: string; byLegacyId?: never } + // | { byId?: never; byEmail?: never; byLegacyId: string | number } + // ``` + if (oneOfDetails.isOneOfInputValue) { + const fieldParts: Array = []; + for (const fieldName of Object.keys(oneOfDetails.parentType.getFields())) { + if (fieldName === node.name.value) { + fieldParts.push(currentInputValue); + continue; + } + fieldParts.push(`${readonlyPart}${fieldName}?: never;`); + } + return indent(`{ ${fieldParts.join(' ')} }`); + } + + // If field is not part of @oneOf input type, then it's a input value, just return as-is + return currentInputValue; + } + public getImports(): Array { return !this.config.globalNamespace && (this.config.inlineFragmentTypes === 'combine' || this.config.inlineFragmentTypes === 'mask') @@ -243,6 +395,43 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< return `Exact<${variablesBlock === '{}' ? `{ [key: string]: never; }` : variablesBlock}>${extraType}`; } + private collectInnerTypesRecursively(node: GraphQLNamedInputType, usedInputTypes: UsedNamedInputTypes): void { + if (usedInputTypes[node.name]) { + return; + } + + if (node instanceof GraphQLEnumType) { + usedInputTypes[node.name] = { + type: 'GraphQLEnumType', + node, + tsType: this.convertName(node.name), + }; + return; + } + + if (node instanceof GraphQLScalarType) { + usedInputTypes[node.name] = { + type: 'GraphQLScalarType', + node, + tsType: (SCALARS[node.name] || this.config.scalars?.[node.name]?.input.type) ?? 'any', + }; + return; + } + + // GraphQLInputObjectType + usedInputTypes[node.name] = { + type: 'GraphQLInputObjectType', + node, + tsType: this.convertName(node.name), + }; + + const fields = node.getFields(); + for (const field of Object.values(fields)) { + const fieldType = getNamedType(field.type); + this.collectInnerTypesRecursively(fieldType, usedInputTypes); + } + } + private collectUsedInputTypes({ schema, documentNode, @@ -254,6 +443,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const usedInputTypes: UsedNamedInputTypes = {}; + // Collect input enums and input types visit(documentNode, { VariableDefinition: variableDefinitionNode => { visit(variableDefinitionNode, { @@ -266,13 +456,38 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< foundInputType instanceof GraphQLEnumType) && !isNativeNamedType(foundInputType) ) { - usedInputTypes[namedTypeNode.name.value] = foundInputType; + this.collectInnerTypesRecursively(foundInputType, usedInputTypes); } }, }); }, }); + // Collect output enums + const typeInfo = new TypeInfo(schema); + visit( + documentNode, + // AST doesn’t include field types (they are defined in schema) - only names. + // TypeInfo is a stateful helper that tracks typing context while walking the AST + // visitWithTypeInfo wires that context into a visitor. + visitWithTypeInfo(typeInfo, { + Field: () => { + const fieldType = typeInfo.getType(); + if (fieldType) { + const namedType = getNamedType(fieldType); + + if (namedType instanceof GraphQLEnumType) { + usedInputTypes[namedType.name] = { + type: 'GraphQLEnumType', + node: namedType, + tsType: this.convertName(namedType.name), + }; + } + } + }, + }) + ); + return usedInputTypes; } @@ -309,3 +524,57 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< return "export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };"; } } + +function parseOneOfInputValue({ + node, + schema, + ancestors, +}: { + node: InputValueDefinitionNode; + schema: GraphQLSchema; + ancestors?: Array; +}): + | { isOneOfInputValue: true; realParentDef: TypeDefinitionNode; parentType: GraphQLInputObjectType } + | { isOneOfInputValue: false } { + const realParentDef = ancestors?.[ancestors.length - 1]; + if (realParentDef) { + const parentType = schema.getType(realParentDef.name.value); + if (isOneOfInputObjectType(parentType)) { + if (node.type.kind === Kind.NON_NULL_TYPE) { + throw new Error( + 'Fields on an input object type can not be non-nullable. It seems like the schema was not validated.' + ); + } + return { isOneOfInputValue: true, realParentDef, parentType }; + } + } + return { isOneOfInputValue: false }; +} + +function collectAndFlattenTypeNodes({ + currentTypeNode, + isPreviousNodeNonNullable, + typeNodes, +}: { + currentTypeNode: TypeNode; + isPreviousNodeNonNullable: boolean; + typeNodes: Array< + { type: 'ListType'; isNonNullable: boolean } | { type: 'NamedType'; isNonNullable: boolean; name: string } + >; +}): void { + if (currentTypeNode.kind === Kind.NON_NULL_TYPE) { + const nextTypeNode = currentTypeNode.type; + collectAndFlattenTypeNodes({ currentTypeNode: nextTypeNode, isPreviousNodeNonNullable: true, typeNodes }); + } else if (currentTypeNode.kind === Kind.LIST_TYPE) { + typeNodes.push({ type: 'ListType', isNonNullable: isPreviousNodeNonNullable }); + + const nextTypeNode = currentTypeNode.type; + collectAndFlattenTypeNodes({ currentTypeNode: nextTypeNode, isPreviousNodeNonNullable: false, typeNodes }); + } else if (currentTypeNode.kind === Kind.NAMED_TYPE) { + typeNodes.push({ + type: 'NamedType', + isNonNullable: isPreviousNodeNonNullable, + name: currentTypeNode.name.value, + }); + } +} diff --git a/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap b/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap index 0ba7941fc12..07c552f2903 100644 --- a/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap +++ b/packages/plugins/typescript/operations/tests/__snapshots__/ts-documents.spec.ts.snap @@ -57,7 +57,11 @@ export type ElementMetadataFragment = `; exports[`TypeScript Operations Plugin > Issues > #2916 - Missing import prefix with preResolveTypes: true and near-operation-file preset 1`] = ` -"export type UserQueryVariables = Exact<{ [key: string]: never; }>; +"export type Department = + | 'Direction' + | 'Development'; + +export type UserQueryVariables = Exact<{ [key: string]: never; }>; export type UserQuery = { user: { id: string, username: string, email: string, dep: Types.Department } }; 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 9796c0cb427..7c8f107aa11 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.import-types.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.import-types.spec.ts index 54ed0cdf2a0..7063e9d09ff 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.import-types.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.import-types.spec.ts @@ -107,6 +107,15 @@ describe('TypeScript Operations Plugin - Import Types', () => { type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** UsersInput Description */ + type UsersInput = { + /** UsersInput from */ + from?: any; + /** UsersInput to */ + to?: any; + role?: UserRole | null | undefined; + }; + export type UserQueryVariables = Exact<{ id: string; }>; @@ -242,6 +251,15 @@ describe('TypeScript Operations Plugin - Import Types', () => { type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** UsersInput Description */ + type UsersInput = { + /** UsersInput from */ + from?: any; + /** UsersInput to */ + to?: any; + role?: UserRole | null | undefined; + }; + export type UserQueryVariables = Exact<{ id: string; }>; diff --git a/packages/plugins/typescript/operations/tests/ts-documents.standalone.input.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.standalone.input.spec.ts new file mode 100644 index 00000000000..2071c3adf0e --- /dev/null +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.input.spec.ts @@ -0,0 +1,370 @@ +import { mergeOutputs } from '@graphql-codegen/plugin-helpers'; +import { validateTs } from '@graphql-codegen/testing'; +import { buildSchema, parse } from 'graphql'; +import { plugin } from '../src/index.js'; + +describe('TypeScript Operations Plugin - Input', () => { + it('generates nested input correctly', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + users(input: UsersInput!): [User!]! + } + + type ResponseError { + error: ResponseErrorType! + } + + enum ResponseErrorType { + NOT_FOUND + INPUT_VALIDATION_ERROR + FORBIDDEN_ERROR + UNEXPECTED_ERROR + } + + type User { + id: ID! + ageRange1: [Int] + ageRange2: [Int]! + ageRange3: [Int!] + ageRange4: [Int!]! + } + + "UserRole Description" + enum UserRole { + "UserRole ADMIN" + ADMIN + "UserRole CUSTOMER" + CUSTOMER + } + + "UsersInput Description" + input UsersInput { + "UsersInput from" + from: DateTime + "UsersInput to" + to: DateTime + timezone: TimeZone + role: UserRole + ageRange1: [Int] + ageRange2: [Int]! + ageRange3: [Int!] + ageRange4: [Int!]! + bestFriend: UsersBestFriendInput + nestedInput: UsersInput + } + + input UsersBestFriendInput { + name: String + } + + scalar DateTime + scalar TimeZone + `); + const document = parse(/* GraphQL */ ` + query UsersWithScalarInput($inputNonNullable: UsersInput!, $inputNullable: UsersInput) { + users(input: $inputNonNullable) { + ageRange1 + ageRange2 + ageRange3 + ageRange4 + } + } + `); + + const result = mergeOutputs([ + await plugin( + schema, + [{ document }], + { + scalars: { + DateTime: 'Date', + }, + }, + { outputFile: '' } + ), + ]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** UserRole Description */ + export type UserRole = + /** UserRole ADMIN */ + | 'ADMIN' + /** UserRole CUSTOMER */ + | 'CUSTOMER'; + + /** UsersInput Description */ + type UsersInput = { + /** UsersInput from */ + from?: Date | null | undefined; + /** UsersInput to */ + to?: Date | null | undefined; + timezone?: any; + role?: UserRole | null | undefined; + ageRange1?: Array | null | undefined; + ageRange2: Array; + ageRange3?: Array | null | undefined; + ageRange4: Array; + bestFriend?: UsersBestFriendInput | null | undefined; + nestedInput?: UsersInput | null | undefined; + }; + + type UsersBestFriendInput = { + name?: string | null | undefined; + }; + + export type UsersWithScalarInputQueryVariables = Exact<{ + inputNonNullable: UsersInput; + inputNullable?: UsersInput | null; + }>; + + + export type UsersWithScalarInputQuery = { __typename?: 'Query', users: Array<{ __typename?: 'User', ageRange1: Array | null, ageRange2: Array, ageRange3: Array | null, ageRange4: Array }> }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + + it('generates readonly input when immutableTypes:true', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + users(input: UsersInput!): [User!]! + } + + type ResponseError { + error: ResponseErrorType! + } + + enum ResponseErrorType { + NOT_FOUND + INPUT_VALIDATION_ERROR + FORBIDDEN_ERROR + UNEXPECTED_ERROR + } + + type User { + id: ID! + ageRange1: [Int] + ageRange2: [Int]! + ageRange3: [Int!] + ageRange4: [Int!]! + } + + "UserRole Description" + enum UserRole { + "UserRole ADMIN" + ADMIN + "UserRole CUSTOMER" + CUSTOMER + } + + "UsersInput Description" + input UsersInput { + "UsersInput from" + from: DateTime + "UsersInput to" + to: DateTime + timezone: TimeZone + role: UserRole + ageRange1: [Int] + ageRange2: [Int]! + ageRange3: [Int!] + ageRange4: [Int!]! + bestFriend: UsersBestFriendInput + nestedInput: UsersInput + } + + input UsersBestFriendInput { + name: String + } + + scalar DateTime + scalar TimeZone + `); + const document = parse(/* GraphQL */ ` + query UsersWithScalarInput($inputNonNullable: UsersInput!, $inputNullable: UsersInput) { + users(input: $inputNonNullable) { + ageRange1 + ageRange2 + ageRange3 + ageRange4 + } + } + `); + + const result = mergeOutputs([ + await plugin( + schema, + [{ document }], + { + scalars: { + DateTime: 'Date', + }, + immutableTypes: true, + }, + { outputFile: '' } + ), + ]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** UserRole Description */ + export type UserRole = + /** UserRole ADMIN */ + | 'ADMIN' + /** UserRole CUSTOMER */ + | 'CUSTOMER'; + + /** UsersInput Description */ + type UsersInput = { + /** UsersInput from */ + readonly from?: Date | null | undefined; + /** UsersInput to */ + readonly to?: Date | null | undefined; + readonly timezone?: any; + readonly role?: UserRole | null | undefined; + readonly ageRange1?: Array | null | undefined; + readonly ageRange2: Array; + readonly ageRange3?: Array | null | undefined; + readonly ageRange4: Array; + readonly bestFriend?: UsersBestFriendInput | null | undefined; + readonly nestedInput?: UsersInput | null | undefined; + }; + + type UsersBestFriendInput = { + readonly name?: string | null | undefined; + }; + + export type UsersWithScalarInputQueryVariables = Exact<{ + inputNonNullable: UsersInput; + inputNullable?: UsersInput | null; + }>; + + + export type UsersWithScalarInputQuery = { readonly __typename?: 'Query', readonly users: ReadonlyArray<{ readonly __typename?: 'User', readonly ageRange1: ReadonlyArray | null, readonly ageRange2: ReadonlyArray, readonly ageRange3: ReadonlyArray | null, readonly ageRange4: ReadonlyArray }> }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + + it('generates @oneOf input correctly', async () => { + const schema = buildSchema(/* GraphQL */ ` + directive @oneOf on INPUT_OBJECT + + type Query { + users(input: UsersInput!): [User!]! + } + + type ResponseError { + error: ResponseErrorType! + } + + enum ResponseErrorType { + NOT_FOUND + INPUT_VALIDATION_ERROR + FORBIDDEN_ERROR + UNEXPECTED_ERROR + } + + type User { + id: ID! + ageRange1: [Int] + ageRange2: [Int]! + ageRange3: [Int!] + ageRange4: [Int!]! + } + + "UserRole Description" + enum UserRole { + "UserRole ADMIN" + ADMIN + "UserRole CUSTOMER" + CUSTOMER + } + + "UsersInput Description" + input UsersInput @oneOf { + "UsersInput from" + from: DateTime + "UsersInput to" + to: DateTime + timezone: TimeZone + role: UserRole + ageRange1: [Int] + ageRange3: [Int!] + bestFriend: UsersBestFriendInput + nestedInput: UsersInput + } + + input UsersBestFriendInput { + name: String + } + + scalar DateTime + scalar TimeZone + `); + const document = parse(/* GraphQL */ ` + query Users($inputNonNullable: UsersInput!, $inputNullable: UsersInput) { + users(input: $inputNonNullable) { + __typename + } + } + `); + + const result = mergeOutputs([ + await plugin( + schema, + [{ document }], + { + scalars: { + DateTime: 'Date', + }, + }, + { outputFile: '' } + ), + ]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** UserRole Description */ + export type UserRole = + /** UserRole ADMIN */ + | 'ADMIN' + /** UserRole CUSTOMER */ + | 'CUSTOMER'; + + /** UsersInput Description */ + type UsersInput = + { /** UsersInput from */ + from: Date; to?: never; timezone?: never; role?: never; ageRange1?: never; ageRange3?: never; bestFriend?: never; nestedInput?: never; } + | { from?: never; /** UsersInput to */ + to: Date; timezone?: never; role?: never; ageRange1?: never; ageRange3?: never; bestFriend?: never; nestedInput?: never; } + | { from?: never; to?: never; timezone: any; role?: never; ageRange1?: never; ageRange3?: never; bestFriend?: never; nestedInput?: never; } + | { from?: never; to?: never; timezone?: never; role: UserRole; ageRange1?: never; ageRange3?: never; bestFriend?: never; nestedInput?: never; } + | { from?: never; to?: never; timezone?: never; role?: never; ageRange1: Array; ageRange3?: never; bestFriend?: never; nestedInput?: never; } + | { from?: never; to?: never; timezone?: never; role?: never; ageRange1?: never; ageRange3: Array; bestFriend?: never; nestedInput?: never; } + | { from?: never; to?: never; timezone?: never; role?: never; ageRange1?: never; ageRange3?: never; bestFriend: UsersBestFriendInput; nestedInput?: never; } + | { from?: never; to?: never; timezone?: never; role?: never; ageRange1?: never; ageRange3?: never; bestFriend?: never; nestedInput: UsersInput; }; + + type UsersBestFriendInput = { + name?: string | null | undefined; + }; + + export type UsersQueryVariables = Exact<{ + inputNonNullable: UsersInput; + inputNullable?: UsersInput | null; + }>; + + + export type UsersQuery = { __typename?: 'Query', users: Array<{ __typename: 'User' }> }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); +}); 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 f00eda06700..c2d4c58e33c 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.standalone.spec.ts @@ -97,6 +97,12 @@ describe('TypeScript Operations Plugin - Standalone', () => { expect(result).toMatchInlineSnapshot(` "type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + export type ResponseErrorType = + | 'NOT_FOUND' + | 'INPUT_VALIDATION_ERROR' + | 'FORBIDDEN_ERROR' + | 'UNEXPECTED_ERROR'; + /** UserRole Description */ export type UserRole = /** UserRole ADMIN */ @@ -104,6 +110,15 @@ describe('TypeScript Operations Plugin - Standalone', () => { /** UserRole CUSTOMER */ | 'CUSTOMER'; + /** UsersInput Description */ + type UsersInput = { + /** UsersInput from */ + from?: any; + /** UsersInput to */ + to?: any; + role?: UserRole | null | undefined; + }; + export type UserQueryVariables = Exact<{ id: string; }>; @@ -139,6 +154,186 @@ describe('TypeScript Operations Plugin - Standalone', () => { // validateTs(content, undefined, undefined, undefined, undefined, true); }); + it('test generating input types enums in lists and inner field', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + users(input: UsersInput!): [User!]! + } + + type User { + id: ID! + } + + enum EnumRoot { + ENUM_A + ENUM_B + } + + enum EnumRootArray { + ENUM_C + ENUM_D + } + + enum EnumInnerArray { + ENUM_E + ENUM_F + } + + input EnumsInner { + enumsDeep: [EnumInnerArray!]! + } + + input UsersInput { + enum: EnumRoot! + enums: [EnumRootArray!]! + innerEnums: EnumsInner! + } + `); + const document = parse(/* GraphQL */ ` + query Users($input: UsersInput!) { + users(input: $input) { + id + } + } + `); + + const result = mergeOutputs([await plugin(schema, [{ document }], {}, { outputFile: '' })]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + export type EnumRoot = + | 'ENUM_A' + | 'ENUM_B'; + + export type EnumRootArray = + | 'ENUM_C' + | 'ENUM_D'; + + export type EnumInnerArray = + | 'ENUM_E' + | 'ENUM_F'; + + type EnumsInner = { + enumsDeep: Array; + }; + + type UsersInput = { + enum: EnumRoot; + enums: Array; + innerEnums: EnumsInner; + }; + + export type UsersQueryVariables = Exact<{ + input: UsersInput; + }>; + + + export type UsersQuery = { __typename?: 'Query', users: Array<{ __typename?: 'User', id: string }> }; + " + `); + }); + + it('test generating output enums in lists and inner field', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + user(id: ID!): User! + } + + enum EnumRoot { + ENUM_A + ENUM_B + } + + enum EnumRootArray { + ENUM_C + ENUM_D + } + + enum EnumInnerArray { + ENUM_E + ENUM_F + } + + type EnumsInner { + enumsDeep: [EnumInnerArray!]! + } + + type User { + enum: EnumRoot! + enums: [EnumRootArray!]! + innerEnums: EnumsInner! + } + `); + const document = parse(/* GraphQL */ ` + query User($id: ID!) { + user(id: $id) { + enum + enums + innerEnums { + enumsDeep + } + } + } + `); + + const result = mergeOutputs([ + await plugin( + schema, + [{ document }], + { + extractAllFieldsToTypes: true, // Extracts all fields to separate types (similar to apollo-codegen behavior) + printFieldsOnNewLines: true, // Prints each field on a new line (similar to apollo-codegen behavior) + }, + { + outputFile: '', + } + ), + ]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + export type EnumRoot = + | 'ENUM_A' + | 'ENUM_B'; + + export type EnumRootArray = + | 'ENUM_C' + | 'ENUM_D'; + + export type EnumInnerArray = + | 'ENUM_E' + | 'ENUM_F'; + + export type UserQuery_user_User_innerEnums_EnumsInner = { + __typename?: 'EnumsInner', + enumsDeep: Array + }; + + export type UserQuery_user_User = { + __typename?: 'User', + enum: EnumRoot, + enums: Array, + innerEnums: UserQuery_user_User_innerEnums_EnumsInner + }; + + export type UserQuery_Query = { + __typename?: 'Query', + user: UserQuery_user_User + }; + + + export type UserQueryVariables = Exact<{ + id: string; + }>; + + + export type UserQuery = UserQuery_Query; + " + `); + }); + it('test overrdiding config.scalars', async () => { const schema = buildSchema(/* GraphQL */ ` type Query { @@ -176,6 +371,121 @@ describe('TypeScript Operations Plugin - Standalone', () => { `); }); + it('test render output enum from fragment in the same document', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum RoleType { + ROLE_A + ROLE_B + } + + type User { + id: ID! + name: String! + role: RoleType + pictureUrl: String + } + + type Query { + users: [User!]! + viewer: User! + } + `); + const document = parse(/* GraphQL */ ` + fragment UserBasic on User { + id + name + role + } + + query GetUsersAndViewer { + users { + ...UserBasic + } + viewer { + ...UserBasic + } + } + `); + + const result = mergeOutputs([await plugin(schema, [{ document }], {}, { outputFile: '' })]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + export type RoleType = + | 'ROLE_A' + | 'ROLE_B'; + + export type UserBasicFragment = { __typename?: 'User', id: string, name: string, role: RoleType | null }; + + export type GetUsersAndViewerQueryVariables = Exact<{ [key: string]: never; }>; + + + export type GetUsersAndViewerQuery = { __typename?: 'Query', users: Array<{ __typename?: 'User', id: string, name: string, role: RoleType | null }>, viewer: { __typename?: 'User', id: string, name: string, role: RoleType | null } }; + " + `); + }); + + it('test render output enum from fragment in a separate document', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum RoleType { + ROLE_A + ROLE_B + } + + type User { + id: ID! + name: String! + role: RoleType + pictureUrl: String + } + + type Query { + users: [User!]! + viewer: User! + } + `); + + const documentWithFragment = parse(/* GraphQL */ ` + fragment UserBasic on User { + id + name + role + } + `); + + const documentMain = parse(/* GraphQL */ ` + query GetUsersAndViewer { + users { + ...UserBasic + } + viewer { + ...UserBasic + } + } + `); + + const result = mergeOutputs([ + await plugin(schema, [{ document: documentMain }, { document: documentWithFragment }], {}, { outputFile: '' }), + ]); + + expect(result).toMatchInlineSnapshot(` + "type Exact = { [K in keyof T]: T[K] }; + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + export type RoleType = + | 'ROLE_A' + | 'ROLE_B'; + + export type GetUsersAndViewerQueryVariables = Exact<{ [key: string]: never; }>; + + + export type GetUsersAndViewerQuery = { __typename?: 'Query', users: Array<{ __typename?: 'User', id: string, name: string, role: RoleType | null }>, viewer: { __typename?: 'User', id: string, name: string, role: RoleType | null } }; + + export type UserBasicFragment = { __typename?: 'User', id: string, name: string, role: RoleType | null }; + " + `); + }); + it('does not generate Variables, Result or Fragments when generatesOperationTypes is false', async () => { const schema = buildSchema(/* GraphQL */ ` type Query { @@ -294,6 +604,12 @@ describe('TypeScript Operations Plugin - Standalone', () => { expect(result).toMatchInlineSnapshot(` " + export type ResponseErrorType = + | 'NOT_FOUND' + | 'INPUT_VALIDATION_ERROR' + | 'FORBIDDEN_ERROR' + | 'UNEXPECTED_ERROR'; + /** UserRole Description */ export type UserRole = /** UserRole ADMIN */ @@ -301,6 +617,14 @@ describe('TypeScript Operations Plugin - Standalone', () => { /** UserRole CUSTOMER */ | 'CUSTOMER'; + /** UsersInput Description */ + type UsersInput = { + /** UsersInput from */ + from?: any; + /** UsersInput to */ + to?: any; + role?: UserRole | null | undefined; + }; " `); diff --git a/packages/presets/client/tests/client-preset.enum.spec.ts b/packages/presets/client/tests/client-preset.enum.spec.ts index 89267a0f05d..58ac2f6f67f 100644 --- a/packages/presets/client/tests/client-preset.enum.spec.ts +++ b/packages/presets/client/tests/client-preset.enum.spec.ts @@ -135,6 +135,10 @@ describe('client-preset - Enum', () => { import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; type Exact = { [K in keyof T]: T[K] }; export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + export type Shape = + | 'ROUND' + | 'SQUARE'; + export type ShapeQueryVariables = Exact<{ [key: string]: never; }>;