Skip to content

Commit

Permalink
feat: support generating correct types for @oneOf input types (#7886)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
n1ru4l committed Jun 6, 2022
1 parent 1c65162 commit c3d7b72
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 12 deletions.
6 changes: 6 additions & 0 deletions .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.
Expand Up @@ -40,6 +40,7 @@ import {
wrapWithSingleQuotes,
getConfigValue,
buildScalarsFromConfig,
isOneOfInputObjectType,
} from './utils';
import { OperationVariablesToObject } from './variables-to-object';
import { parseEnumValues } from './enum-values';
Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 25 additions & 0 deletions packages/plugins/other/visitor-plugin-common/src/utils.ts
Expand Up @@ -19,6 +19,8 @@ import {
isListType,
isAbstractType,
GraphQLOutputType,
isInputObjectType,
GraphQLInputObjectType,
} from 'graphql';
import { ScalarsMap, NormalizedScalarsMap, ParsedScalarsMap } from './types';
import { DEFAULT_SCALARS } from './scalars';
Expand Down Expand Up @@ -495,3 +497,26 @@ function clearOptional(str: string): string {
function stripTrailingSpaces(str: string): string {
return str.replace(/ +\n/g, '\n');
}

const isOneOfTypeCache = new WeakMap<GraphQLNamedType, boolean>();
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;
}
6 changes: 3 additions & 3 deletions packages/plugins/typescript/type-graphql/src/visitor.ts
Expand Up @@ -415,12 +415,12 @@ export class TypeGraphQLVisitor<
node: InputValueDefinitionNode,
key?: number | string,
parent?: any,
path?: any,
ancestors?: TypeDefinitionNode[]
path?: Array<string | number>,
ancestors?: Array<TypeDefinitionNode>
): 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;
Expand Down
51 changes: 42 additions & 9 deletions packages/plugins/typescript/typescript/src/visitor.ts
Expand Up @@ -9,6 +9,7 @@ import {
DeclarationKind,
normalizeAvoidOptionals,
AvoidOptionalsConfig,
isOneOfInputObjectType,
} from '@graphql-codegen/visitor-plugin-common';
import { TypeScriptPluginConfig } from './config';
import autoBind from 'auto-bind';
Expand All @@ -24,6 +25,7 @@ import {
isEnumType,
UnionTypeDefinitionNode,
GraphQLObjectType,
TypeDefinitionNode,
} from 'graphql';
import { TypeScriptOperationVariablesToObject } from './typescript-variables-to-object';

Expand Down Expand Up @@ -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<string | number>,
ancestors?: Array<TypeDefinitionNode>
): string {
const originalFieldNode = parent[key] as FieldDefinitionNode;

const addOptionalSign =
!this.config.avoidOptionals.inputValue &&
(originalFieldNode.type.kind !== Kind.NON_NULL_TYPE ||
Expand All @@ -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<string> = [];
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 {
Expand Down
124 changes: 124 additions & 0 deletions packages/plugins/typescript/typescript/tests/typescript.spec.ts
Expand Up @@ -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', () => {
Expand Down

1 comment on commit c3d7b72

@vercel
Copy link

@vercel vercel bot commented on c3d7b72 Jun 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.