Skip to content

Commit c3d7b72

Browse files
authored
feat: support generating correct types for @oneOf input types (#7886)
* feat: support generating correct types for `@oneOf` input types * fix: compatibility with type-graphql plugin + stricter TypeScript annotations * feat: make types more friendly for implementing resolver functions * fix: ensure the required key cannot be undefined * fix: apply oneOf constraints and support the .isOneOf property on input object types * fix: remove MaybeInput wrapper from input field types * chore: remove unused code * Apply suggestions from code review * put stuff int the cache
1 parent 1c65162 commit c3d7b72

File tree

6 files changed

+215
-12
lines changed

6 files changed

+215
-12
lines changed

‎.changeset/wicked-geckos-do.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@graphql-codegen/visitor-plugin-common': minor
3+
'@graphql-codegen/typescript': minor
4+
---
5+
6+
support the `@oneOf` directive on input types.

‎packages/plugins/other/visitor-plugin-common/src/base-types-visitor.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
wrapWithSingleQuotes,
4141
getConfigValue,
4242
buildScalarsFromConfig,
43+
isOneOfInputObjectType,
4344
} from './utils';
4445
import { OperationVariablesToObject } from './variables-to-object';
4546
import { parseEnumValues } from './enum-values';
@@ -453,9 +454,23 @@ export class BaseTypesVisitor<
453454
.withBlock(node.fields.join('\n'));
454455
}
455456

457+
getInputObjectOneOfDeclarationBlock(node: InputObjectTypeDefinitionNode): DeclarationBlock {
458+
return new DeclarationBlock(this._declarationBlockConfig)
459+
.export()
460+
.asKind(this._parsedConfig.declarationKind.input)
461+
.withName(this.convertName(node))
462+
.withComment(node.description as any as string)
463+
.withContent(`\n` + node.fields.join('\n |'));
464+
}
465+
456466
InputObjectTypeDefinition(node: InputObjectTypeDefinitionNode): string {
457467
if (this.config.onlyEnums) return '';
458468

469+
// Why the heck is node.name a string and not { value: string } at runtime ?!
470+
if (isOneOfInputObjectType(this._schema.getType(node.name as unknown as string))) {
471+
return this.getInputObjectOneOfDeclarationBlock(node).string;
472+
}
473+
459474
return this.getInputObjectDeclarationBlock(node).string;
460475
}
461476

‎packages/plugins/other/visitor-plugin-common/src/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
isListType,
2020
isAbstractType,
2121
GraphQLOutputType,
22+
isInputObjectType,
23+
GraphQLInputObjectType,
2224
} from 'graphql';
2325
import { ScalarsMap, NormalizedScalarsMap, ParsedScalarsMap } from './types';
2426
import { DEFAULT_SCALARS } from './scalars';
@@ -495,3 +497,26 @@ function clearOptional(str: string): string {
495497
function stripTrailingSpaces(str: string): string {
496498
return str.replace(/ +\n/g, '\n');
497499
}
500+
501+
const isOneOfTypeCache = new WeakMap<GraphQLNamedType, boolean>();
502+
export function isOneOfInputObjectType(
503+
namedType: GraphQLNamedType | null | undefined
504+
): namedType is GraphQLInputObjectType {
505+
if (!namedType) {
506+
return false;
507+
}
508+
let isOneOfType = isOneOfTypeCache.get(namedType);
509+
510+
if (isOneOfType !== undefined) {
511+
return isOneOfType;
512+
}
513+
514+
isOneOfType =
515+
isInputObjectType(namedType) &&
516+
((namedType as unknown as Record<'isOneOf', boolean | undefined>).isOneOf ||
517+
namedType.astNode?.directives?.some(d => d.name.value === 'oneOf'));
518+
519+
isOneOfTypeCache.set(namedType, isOneOfType);
520+
521+
return isOneOfType;
522+
}

‎packages/plugins/typescript/type-graphql/src/visitor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,12 +415,12 @@ export class TypeGraphQLVisitor<
415415
node: InputValueDefinitionNode,
416416
key?: number | string,
417417
parent?: any,
418-
path?: any,
419-
ancestors?: TypeDefinitionNode[]
418+
path?: Array<string | number>,
419+
ancestors?: Array<TypeDefinitionNode>
420420
): string {
421421
const parentName = ancestors?.[ancestors.length - 1].name.value;
422422
if (parent && !this.hasTypeDecorators(parentName)) {
423-
return this.typescriptVisitor.InputValueDefinition(node, key, parent);
423+
return this.typescriptVisitor.InputValueDefinition(node, key, parent, path, ancestors);
424424
}
425425

426426
const fieldDecorator = this.config.decoratorName.field;

‎packages/plugins/typescript/typescript/src/visitor.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
DeclarationKind,
1010
normalizeAvoidOptionals,
1111
AvoidOptionalsConfig,
12+
isOneOfInputObjectType,
1213
} from '@graphql-codegen/visitor-plugin-common';
1314
import { TypeScriptPluginConfig } from './config';
1415
import autoBind from 'auto-bind';
@@ -24,6 +25,7 @@ import {
2425
isEnumType,
2526
UnionTypeDefinitionNode,
2627
GraphQLObjectType,
28+
TypeDefinitionNode,
2729
} from 'graphql';
2830
import { TypeScriptOperationVariablesToObject } from './typescript-variables-to-object';
2931

@@ -280,8 +282,15 @@ export class TsVisitor<
280282
);
281283
}
282284

283-
InputValueDefinition(node: InputValueDefinitionNode, key?: number | string, parent?: any): string {
285+
InputValueDefinition(
286+
node: InputValueDefinitionNode,
287+
key?: number | string,
288+
parent?: any,
289+
_path?: Array<string | number>,
290+
ancestors?: Array<TypeDefinitionNode>
291+
): string {
284292
const originalFieldNode = parent[key] as FieldDefinitionNode;
293+
285294
const addOptionalSign =
286295
!this.config.avoidOptionals.inputValue &&
287296
(originalFieldNode.type.kind !== Kind.NON_NULL_TYPE ||
@@ -294,14 +303,38 @@ export class TsVisitor<
294303
type = this._getDirectiveOverrideType(node.directives) || type;
295304
}
296305

297-
return (
298-
comment +
299-
indent(
300-
`${this.config.immutableTypes ? 'readonly ' : ''}${node.name}${
301-
addOptionalSign ? '?' : ''
302-
}: ${type}${this.getPunctuation(declarationKind)}`
303-
)
304-
);
306+
const readonlyPrefix = this.config.immutableTypes ? 'readonly ' : '';
307+
308+
const buildFieldDefinition = (isOneOf = false) => {
309+
return `${readonlyPrefix}${node.name}${addOptionalSign && !isOneOf ? '?' : ''}: ${
310+
isOneOf ? this.clearOptional(type) : type
311+
}${this.getPunctuation(declarationKind)}`;
312+
};
313+
314+
const realParentDef = ancestors?.[ancestors.length - 1];
315+
if (realParentDef) {
316+
const parentType = this._schema.getType(realParentDef.name.value);
317+
318+
if (isOneOfInputObjectType(parentType)) {
319+
if (originalFieldNode.type.kind === Kind.NON_NULL_TYPE) {
320+
throw new Error(
321+
'Fields on an input object type can not be non-nullable. It seems like the schema was not validated.'
322+
);
323+
}
324+
const fieldParts: Array<string> = [];
325+
for (const fieldName of Object.keys(parentType.getFields())) {
326+
// Why the heck is node.name a string and not { value: string } at runtime ?!
327+
if (fieldName === (node.name as any as string)) {
328+
fieldParts.push(buildFieldDefinition(true));
329+
continue;
330+
}
331+
fieldParts.push(`${readonlyPrefix}${fieldName}?: never;`);
332+
}
333+
return comment + indent(`{ ${fieldParts.join(' ')} }`);
334+
}
335+
}
336+
337+
return comment + indent(buildFieldDefinition());
305338
}
306339

307340
EnumTypeDefinition(node: EnumTypeDefinitionNode): string {

‎packages/plugins/typescript/typescript/tests/typescript.spec.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2536,6 +2536,130 @@ describe('TypeScript', () => {
25362536
`);
25372537
validateTs(result);
25382538
});
2539+
2540+
describe('@oneOf on input types', () => {
2541+
const oneOfDirectiveDefinition = /* GraphQL */ `
2542+
directive @oneOf on INPUT_OBJECT
2543+
`;
2544+
2545+
it('correct output for type with single field', async () => {
2546+
const schema = buildSchema(
2547+
/* GraphQL */ `
2548+
input Input @oneOf {
2549+
int: Int
2550+
}
2551+
2552+
type Query {
2553+
foo(input: Input!): Boolean!
2554+
}
2555+
`.concat(oneOfDirectiveDefinition)
2556+
);
2557+
2558+
const result = await plugin(schema, [], {}, { outputFile: '' });
2559+
2560+
expect(result.content).toBeSimilarStringTo(`
2561+
export type Input =
2562+
{ int: Scalars['Int']; };
2563+
`);
2564+
});
2565+
2566+
it('correct output for type with multiple fields', async () => {
2567+
const schema = buildSchema(
2568+
/* GraphQL */ `
2569+
input Input @oneOf {
2570+
int: Int
2571+
boolean: Boolean
2572+
}
2573+
2574+
type Query {
2575+
foo(input: Input!): Boolean!
2576+
}
2577+
`.concat(oneOfDirectiveDefinition)
2578+
);
2579+
2580+
const result = await plugin(schema, [], {}, { outputFile: '' });
2581+
2582+
expect(result.content).toBeSimilarStringTo(`
2583+
export type Input =
2584+
{ int: Scalars['Int']; boolean?: never; }
2585+
| { int?: never; boolean: Scalars['Boolean']; };
2586+
`);
2587+
});
2588+
2589+
it('raises exception for type with non-optional fields', async () => {
2590+
const schema = buildSchema(
2591+
/* GraphQL */ `
2592+
input Input @oneOf {
2593+
int: Int!
2594+
boolean: Boolean!
2595+
}
2596+
2597+
type Query {
2598+
foo(input: Input!): Boolean!
2599+
}
2600+
`.concat(oneOfDirectiveDefinition)
2601+
);
2602+
2603+
try {
2604+
await plugin(schema, [], {}, { outputFile: '' });
2605+
throw new Error('Plugin should have raised an exception.');
2606+
} catch (err) {
2607+
expect(err.message).toEqual(
2608+
'Fields on an input object type can not be non-nullable. It seems like the schema was not validated.'
2609+
);
2610+
}
2611+
});
2612+
2613+
it('handles extensions properly', async () => {
2614+
const schema = buildSchema(
2615+
/* GraphQL */ `
2616+
input Input @oneOf {
2617+
int: Int
2618+
}
2619+
2620+
extend input Input {
2621+
boolean: Boolean
2622+
}
2623+
2624+
type Query {
2625+
foo(input: Input!): Boolean!
2626+
}
2627+
`.concat(oneOfDirectiveDefinition)
2628+
);
2629+
2630+
const result = await plugin(schema, [], {}, { outputFile: '' });
2631+
expect(result.content).toBeSimilarStringTo(`
2632+
export type Input =
2633+
{ int: Scalars['Int']; boolean?: never; }
2634+
| { int?: never; boolean: Scalars['Boolean']; };
2635+
`);
2636+
});
2637+
2638+
it('handles .isOneOf property on input object types properly', async () => {
2639+
const schema = buildSchema(
2640+
/* GraphQL */ `
2641+
input Input {
2642+
int: Int
2643+
boolean: Boolean
2644+
}
2645+
2646+
type Query {
2647+
foo(input: Input!): Boolean!
2648+
}
2649+
`.concat(oneOfDirectiveDefinition)
2650+
);
2651+
2652+
const inputType: Record<'isOneOf', boolean> = schema.getType('Input') as any;
2653+
inputType.isOneOf = true;
2654+
2655+
const result = await plugin(schema, [], {}, { outputFile: '' });
2656+
expect(result.content).toBeSimilarStringTo(`
2657+
export type Input =
2658+
{ int: Scalars['Int']; boolean?: never; }
2659+
| { int?: never; boolean: Scalars['Boolean']; };
2660+
`);
2661+
});
2662+
});
25392663
});
25402664

25412665
describe('Naming Convention & Types Prefix', () => {

0 commit comments

Comments
 (0)