diff --git a/.changeset/wicked-geckos-do.md b/.changeset/wicked-geckos-do.md new file mode 100644 index 00000000000..e61cc5cb5ec --- /dev/null +++ b/.changeset/wicked-geckos-do.md @@ -0,0 +1,6 @@ +--- +'@graphql-codegen/visitor-plugin-common': minor +'@graphql-codegen/typescript': minor +--- + +support the `@oneOf` directive on input types. diff --git a/packages/plugins/other/visitor-plugin-common/src/base-types-visitor.ts b/packages/plugins/other/visitor-plugin-common/src/base-types-visitor.ts index ae377443bbe..705e562f010 100644 --- a/packages/plugins/other/visitor-plugin-common/src/base-types-visitor.ts +++ b/packages/plugins/other/visitor-plugin-common/src/base-types-visitor.ts @@ -40,6 +40,7 @@ import { wrapWithSingleQuotes, getConfigValue, buildScalarsFromConfig, + isOneOfInputObjectType, } from './utils'; import { OperationVariablesToObject } from './variables-to-object'; import { parseEnumValues } from './enum-values'; @@ -453,9 +454,23 @@ export class BaseTypesVisitor< .withBlock(node.fields.join('\n')); } + getInputObjectOneOfDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock { + return new DeclarationBlock(this._declarationBlockConfig) + .export() + .asKind(this._parsedConfig.declarationKind.input) + .withName(this.convertName(node)) + .withComment(node.description as any as string) + .withContent(`\n` + node.fields.join('\n |')); + } + InputObjectTypeDefinition(node: InputObjectTypeDefinitionNode): string { if (this.config.onlyEnums) return ''; + // Why the heck is node.name a string and not { value: string } at runtime ?! + if (isOneOfInputObjectType(this._schema.getType(node.name as unknown as string))) { + return this.getInputObjectOneOfDeclarationBlock(node).string; + } + return this.getInputObjectDeclarationBlock(node).string; } diff --git a/packages/plugins/other/visitor-plugin-common/src/utils.ts b/packages/plugins/other/visitor-plugin-common/src/utils.ts index 137dbc8e54b..ede9e408565 100644 --- a/packages/plugins/other/visitor-plugin-common/src/utils.ts +++ b/packages/plugins/other/visitor-plugin-common/src/utils.ts @@ -19,6 +19,8 @@ import { isListType, isAbstractType, GraphQLOutputType, + isInputObjectType, + GraphQLInputObjectType, } from 'graphql'; import { ScalarsMap, NormalizedScalarsMap, ParsedScalarsMap } from './types'; import { DEFAULT_SCALARS } from './scalars'; @@ -495,3 +497,26 @@ function clearOptional(str: string): string { function stripTrailingSpaces(str: string): string { return str.replace(/ +\n/g, '\n'); } + +const isOneOfTypeCache = new WeakMap(); +export function isOneOfInputObjectType( + namedType: GraphQLNamedType | null | undefined +): namedType is GraphQLInputObjectType { + if (!namedType) { + return false; + } + let isOneOfType = isOneOfTypeCache.get(namedType); + + if (isOneOfType !== undefined) { + return isOneOfType; + } + + isOneOfType = + isInputObjectType(namedType) && + ((namedType as unknown as Record<'isOneOf', boolean | undefined>).isOneOf || + namedType.astNode?.directives?.some(d => d.name.value === 'oneOf')); + + isOneOfTypeCache.set(namedType, isOneOfType); + + return isOneOfType; +} diff --git a/packages/plugins/typescript/type-graphql/src/visitor.ts b/packages/plugins/typescript/type-graphql/src/visitor.ts index 3782cd27b2e..3546192adec 100644 --- a/packages/plugins/typescript/type-graphql/src/visitor.ts +++ b/packages/plugins/typescript/type-graphql/src/visitor.ts @@ -415,12 +415,12 @@ export class TypeGraphQLVisitor< node: InputValueDefinitionNode, key?: number | string, parent?: any, - path?: any, - ancestors?: TypeDefinitionNode[] + path?: Array, + ancestors?: Array ): string { const parentName = ancestors?.[ancestors.length - 1].name.value; if (parent && !this.hasTypeDecorators(parentName)) { - return this.typescriptVisitor.InputValueDefinition(node, key, parent); + return this.typescriptVisitor.InputValueDefinition(node, key, parent, path, ancestors); } const fieldDecorator = this.config.decoratorName.field; diff --git a/packages/plugins/typescript/typescript/src/visitor.ts b/packages/plugins/typescript/typescript/src/visitor.ts index b4ebfeb7f76..5994569f5e7 100644 --- a/packages/plugins/typescript/typescript/src/visitor.ts +++ b/packages/plugins/typescript/typescript/src/visitor.ts @@ -9,6 +9,7 @@ import { DeclarationKind, normalizeAvoidOptionals, AvoidOptionalsConfig, + isOneOfInputObjectType, } from '@graphql-codegen/visitor-plugin-common'; import { TypeScriptPluginConfig } from './config'; import autoBind from 'auto-bind'; @@ -24,6 +25,7 @@ import { isEnumType, UnionTypeDefinitionNode, GraphQLObjectType, + TypeDefinitionNode, } from 'graphql'; import { TypeScriptOperationVariablesToObject } from './typescript-variables-to-object'; @@ -280,8 +282,15 @@ export class TsVisitor< ); } - InputValueDefinition(node: InputValueDefinitionNode, key?: number | string, parent?: any): string { + InputValueDefinition( + node: InputValueDefinitionNode, + key?: number | string, + parent?: any, + _path?: Array, + ancestors?: Array + ): string { const originalFieldNode = parent[key] as FieldDefinitionNode; + const addOptionalSign = !this.config.avoidOptionals.inputValue && (originalFieldNode.type.kind !== Kind.NON_NULL_TYPE || @@ -294,14 +303,38 @@ export class TsVisitor< type = this._getDirectiveOverrideType(node.directives) || type; } - return ( - comment + - indent( - `${this.config.immutableTypes ? 'readonly ' : ''}${node.name}${ - addOptionalSign ? '?' : '' - }: ${type}${this.getPunctuation(declarationKind)}` - ) - ); + const readonlyPrefix = this.config.immutableTypes ? 'readonly ' : ''; + + const buildFieldDefinition = (isOneOf = false) => { + return `${readonlyPrefix}${node.name}${addOptionalSign && !isOneOf ? '?' : ''}: ${ + isOneOf ? this.clearOptional(type) : type + }${this.getPunctuation(declarationKind)}`; + }; + + const realParentDef = ancestors?.[ancestors.length - 1]; + if (realParentDef) { + const parentType = this._schema.getType(realParentDef.name.value); + + if (isOneOfInputObjectType(parentType)) { + if (originalFieldNode.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.' + ); + } + const fieldParts: Array = []; + for (const fieldName of Object.keys(parentType.getFields())) { + // Why the heck is node.name a string and not { value: string } at runtime ?! + if (fieldName === (node.name as any as string)) { + fieldParts.push(buildFieldDefinition(true)); + continue; + } + fieldParts.push(`${readonlyPrefix}${fieldName}?: never;`); + } + return comment + indent(`{ ${fieldParts.join(' ')} }`); + } + } + + return comment + indent(buildFieldDefinition()); } EnumTypeDefinition(node: EnumTypeDefinitionNode): string { diff --git a/packages/plugins/typescript/typescript/tests/typescript.spec.ts b/packages/plugins/typescript/typescript/tests/typescript.spec.ts index 66b39de3378..72834a634e7 100644 --- a/packages/plugins/typescript/typescript/tests/typescript.spec.ts +++ b/packages/plugins/typescript/typescript/tests/typescript.spec.ts @@ -2536,6 +2536,130 @@ describe('TypeScript', () => { `); validateTs(result); }); + + describe('@oneOf on input types', () => { + const oneOfDirectiveDefinition = /* GraphQL */ ` + directive @oneOf on INPUT_OBJECT + `; + + it('correct output for type with single field', async () => { + const schema = buildSchema( + /* GraphQL */ ` + input Input @oneOf { + int: Int + } + + type Query { + foo(input: Input!): Boolean! + } + `.concat(oneOfDirectiveDefinition) + ); + + const result = await plugin(schema, [], {}, { outputFile: '' }); + + expect(result.content).toBeSimilarStringTo(` + export type Input = + { int: Scalars['Int']; }; + `); + }); + + it('correct output for type with multiple fields', async () => { + const schema = buildSchema( + /* GraphQL */ ` + input Input @oneOf { + int: Int + boolean: Boolean + } + + type Query { + foo(input: Input!): Boolean! + } + `.concat(oneOfDirectiveDefinition) + ); + + const result = await plugin(schema, [], {}, { outputFile: '' }); + + expect(result.content).toBeSimilarStringTo(` + export type Input = + { int: Scalars['Int']; boolean?: never; } + | { int?: never; boolean: Scalars['Boolean']; }; + `); + }); + + it('raises exception for type with non-optional fields', async () => { + const schema = buildSchema( + /* GraphQL */ ` + input Input @oneOf { + int: Int! + boolean: Boolean! + } + + type Query { + foo(input: Input!): Boolean! + } + `.concat(oneOfDirectiveDefinition) + ); + + try { + await plugin(schema, [], {}, { outputFile: '' }); + throw new Error('Plugin should have raised an exception.'); + } catch (err) { + expect(err.message).toEqual( + 'Fields on an input object type can not be non-nullable. It seems like the schema was not validated.' + ); + } + }); + + it('handles extensions properly', async () => { + const schema = buildSchema( + /* GraphQL */ ` + input Input @oneOf { + int: Int + } + + extend input Input { + boolean: Boolean + } + + type Query { + foo(input: Input!): Boolean! + } + `.concat(oneOfDirectiveDefinition) + ); + + const result = await plugin(schema, [], {}, { outputFile: '' }); + expect(result.content).toBeSimilarStringTo(` + export type Input = + { int: Scalars['Int']; boolean?: never; } + | { int?: never; boolean: Scalars['Boolean']; }; + `); + }); + + it('handles .isOneOf property on input object types properly', async () => { + const schema = buildSchema( + /* GraphQL */ ` + input Input { + int: Int + boolean: Boolean + } + + type Query { + foo(input: Input!): Boolean! + } + `.concat(oneOfDirectiveDefinition) + ); + + const inputType: Record<'isOneOf', boolean> = schema.getType('Input') as any; + inputType.isOneOf = true; + + const result = await plugin(schema, [], {}, { outputFile: '' }); + expect(result.content).toBeSimilarStringTo(` + export type Input = + { int: Scalars['Int']; boolean?: never; } + | { int?: never; boolean: Scalars['Boolean']; }; + `); + }); + }); }); describe('Naming Convention & Types Prefix', () => {