From 577a68d7d99d0e80840c1aeef75cecfbb8ea640a Mon Sep 17 00:00:00 2001 From: Nathan Regner Date: Fri, 4 Oct 2024 17:20:25 -0600 Subject: [PATCH] feat: omit unused input/enum types when `onlyOperationTypes` is enabled --- ...ypes.preResolveTypes.onlyOperationTypes.ts | 8 - .../src/base-types-visitor.ts | 14 +- .../__snapshots__/ts-documents.spec.ts.snap | 25 +++ .../operations/tests/ts-documents.spec.ts | 44 +++++ .../typescript/typescript/src/config.ts | 2 +- .../typescript/typescript/src/index.ts | 76 ++++++++- .../typescript/typescript/src/visitor.ts | 9 ++ .../typescript/tests/typescript.spec.ts | 152 +++++++++++++++++- packages/utils/plugins-helpers/src/utils.ts | 14 +- website/public/config.schema.json | 41 ++++- 10 files changed, 364 insertions(+), 21 deletions(-) diff --git a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts index 7ec1e20377e..49bb586ea16 100644 --- a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts @@ -31,14 +31,6 @@ export enum Episode { Newhope = 'NEWHOPE', } -/** Units of height */ -export enum LengthUnit { - /** Primarily used in the United States */ - Foot = 'FOOT', - /** The standard unit around the world */ - Meter = 'METER', -} - /** The input object sent when someone is creating a new review */ export type ReviewInput = { /** Comment about the movie, optional */ 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 92e75e63bd5..19c38289f27 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 @@ -60,6 +60,8 @@ export interface ParsedTypesConfig extends ParsedConfig { wrapEntireDefinitions: boolean; ignoreEnumValuesFromSchema: boolean; directiveArgumentAndInputFieldMappings: ParsedDirectiveArgumentAndInputFieldMappings; + /** When non-null, contains a subset of input types & enums that should be generated. See `onlyOperationTypes` */ + usedTypes?: Set; } export interface RawTypesConfig extends RawConfig { @@ -331,7 +333,7 @@ export interface RawTypesConfig extends RawConfig { */ onlyEnums?: boolean; /** - * @description This will cause the generator to emit types for operations only (basically only enums and scalars) + * @description This will cause the generator to only emit types used by one or more operations (basically only enums, inputs, and scalars). * @default false * * @exampleMarkdown @@ -675,7 +677,15 @@ export class BaseTypesVisitor< } InputObjectTypeDefinition(node: InputObjectTypeDefinitionNode): string { - if (this.config.onlyEnums) return ''; + if ( + (this.config.onlyOperationTypes && + !this.config.usedTypes?.has( + // types are wrong; string at runtime? + node.name as unknown as string + )) || + 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))) { 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 b363ec0e715..c934c1767ae 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 @@ -157,6 +157,31 @@ export type SnakeQueryQuery = { __typename: 'Query', snake: { __typename: 'Snake " `; +exports[`TypeScript Operations Plugin Operation Definition should only emit used enums when onlyOperationTypes=true 1`] = ` +"/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } +}; + +export type InfoInput = { + type: InputEnum; +}; + +export enum InputEnum { + Name = 'NAME', + Address = 'ADDRESS' +} + +export enum OutputEnum { + Keep = 'KEEP' +} +" +`; + exports[`TypeScript Operations Plugin Selection Set Should generate the correct __typename when using both inline fragment and spread over type 1`] = ` "export type UserQueryQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts index f37405c3b77..f38acdbc1e2 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts @@ -3305,6 +3305,50 @@ describe('TypeScript Operations Plugin', () => { }>; `); }); + + it('should only emit used enums when onlyOperationTypes=true', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + type Query { + info(input: InfoInput, unusedEnum: UnusedEnum = null, unusedType: UnusedType = null): InfoOutput + } + + input InfoInput { + type: InputEnum! + } + + enum InputEnum { + NAME + ADDRESS + } + + type InfoOutput { + type: OutputEnum! + } + + enum OutputEnum { + KEEP + } + + input UnusedType { + type: UnusedEnum! + } + + enum UnusedEnum { + UNUSED + } + `); + + const document = parse(/* GraphQL */ ` + query InfoQuery($input: InfoInput) { + info(input: $input, unusedEnum: UNUSED) { + type + } + } + `); + + const { content } = await tsPlugin(testSchema, [{ location: '', document }], { onlyOperationTypes: true }, {}); + expect(content).toMatchSnapshot(); + }); }); describe('Union & Interfaces', () => { diff --git a/packages/plugins/typescript/typescript/src/config.ts b/packages/plugins/typescript/typescript/src/config.ts index b9aed7cf961..43520241353 100644 --- a/packages/plugins/typescript/typescript/src/config.ts +++ b/packages/plugins/typescript/typescript/src/config.ts @@ -224,7 +224,7 @@ export interface TypeScriptPluginConfig extends RawTypesConfig { */ onlyEnums?: boolean; /** - * @description This will cause the generator to emit types for operations only (basically only enums and scalars). + * @description This will cause the generator to only emit types used by one or more operations (basically only enums, inputs, and scalars). * Interacts well with `preResolveTypes: true` * @default false * diff --git a/packages/plugins/typescript/typescript/src/index.ts b/packages/plugins/typescript/typescript/src/index.ts index aa1e180ee9f..34a157979f4 100644 --- a/packages/plugins/typescript/typescript/src/index.ts +++ b/packages/plugins/typescript/typescript/src/index.ts @@ -1,6 +1,7 @@ -import { oldVisit, PluginFunction, Types } from '@graphql-codegen/plugin-helpers'; +import { getBaseType, oldVisit, PluginFunction, Types } from '@graphql-codegen/plugin-helpers'; import { transformSchemaAST } from '@graphql-codegen/schema-ast'; import { + buildASTSchema, DocumentNode, getNamedType, GraphQLNamedType, @@ -12,10 +13,14 @@ import { TypeInfo, visit, visitWithTypeInfo, + GraphQLEnumType, + GraphQLInputObjectType, } from 'graphql'; import { TypeScriptPluginConfig } from './config.js'; import { TsIntrospectionVisitor } from './introspection-visitor.js'; import { TsVisitor } from './visitor.js'; +import { getCachedDocumentNodeFromSchema } from '@graphql-codegen/plugin-helpers'; +import { getBaseTypeNode } from '@graphql-codegen/visitor-plugin-common'; export * from './config.js'; export * from './introspection-visitor.js'; @@ -29,7 +34,12 @@ export const plugin: PluginFunction { const { schema: _schema, ast } = transformSchemaAST(schema, config); - const visitor = new TsVisitor(_schema, config); + let usedTypes = undefined; + if (config.onlyOperationTypes) { + usedTypes = getUsedTypeNames(schema, documents); + } + + const visitor = new TsVisitor(_schema, config, { usedTypes }); const visitorResult = oldVisit(ast, { leave: visitor }); const introspectionDefinitions = includeIntrospectionTypesDefinitions(_schema, documents, config); @@ -62,7 +72,7 @@ export function includeIntrospectionTypesDefinitions( const typeInfo = new TypeInfo(schema); const usedTypes: GraphQLNamedType[] = []; const documentsVisitor = visitWithTypeInfo(typeInfo, { - Field() { + Field(node) { const type = getNamedType(typeInfo.getType()); if (type && isIntrospectionType(type) && !usedTypes.includes(type)) { @@ -106,3 +116,63 @@ export function includeIntrospectionTypesDefinitions( return result.definitions as any[]; } + +export function getUsedTypeNames(schema: GraphQLSchema, documents: Types.DocumentFile[]): Set { + if (!schema.astNode) { + const ast = getCachedDocumentNodeFromSchema(schema); + schema = buildASTSchema(ast); + } + + const typeInfo = new TypeInfo(schema); + + const visited = new Set(); + const queue: GraphQLNamedType[] = []; + + function enqueue(type: GraphQLNamedType) { + if ( + type.astNode && // skip scalars + !visited.has(type.name) + ) { + visited.add(type.name); + queue.push(type); + } + } + + const visitor = visitWithTypeInfo(typeInfo, { + VariableDefinition() { + const field = typeInfo.getInputType(); + const type = getBaseType(field); + enqueue(type); + }, + Field() { + const field = typeInfo.getFieldDef(); + const type = getBaseType(field.type); + if (type instanceof GraphQLEnumType || type instanceof GraphQLInputObjectType) { + enqueue(type); + } + }, + InputObjectTypeDefinition(node) { + for (const field of node.fields ?? []) { + const baseType = getBaseTypeNode(field.type); + const expanded = schema.getType(baseType.name.value); + if (expanded.name) { + enqueue(expanded); + } + } + }, + }); + + for (const doc of documents) { + visit(doc.document, visitor); + } + + const typeNames = new Set(); + while (true) { + const type = queue.pop(); + if (!type) break; + typeNames.add(type.name); + visit(type.astNode, visitor); + } + + return typeNames; +} diff --git a/packages/plugins/typescript/typescript/src/visitor.ts b/packages/plugins/typescript/typescript/src/visitor.ts index 55c4ebd33c9..1991d02a3af 100644 --- a/packages/plugins/typescript/typescript/src/visitor.ts +++ b/packages/plugins/typescript/typescript/src/visitor.ts @@ -351,6 +351,15 @@ export class TsVisitor< } EnumTypeDefinition(node: EnumTypeDefinitionNode): string { + if ( + this.config.onlyOperationTypes && + !this.config.usedTypes?.has( + // types are wrong; string at runtime? + node.name as unknown as string + ) && + !this.config.onlyEnums + ) + return ''; const enumName = node.name as any as string; // In case of mapped external enum string diff --git a/packages/plugins/typescript/typescript/tests/typescript.spec.ts b/packages/plugins/typescript/typescript/tests/typescript.spec.ts index 88383bd5840..14880bde159 100644 --- a/packages/plugins/typescript/typescript/tests/typescript.spec.ts +++ b/packages/plugins/typescript/typescript/tests/typescript.spec.ts @@ -1,6 +1,7 @@ import { mergeOutputs, Types } from '@graphql-codegen/plugin-helpers'; +import { getUsedTypeNames } from '@graphql-codegen/typescript'; import { validateTs } from '@graphql-codegen/testing'; -import { buildSchema, GraphQLEnumType, GraphQLObjectType, GraphQLSchema, parse } from 'graphql'; +import { buildSchema, GraphQLEnumType, GraphQLObjectType, GraphQLSchema, parse, validate } from 'graphql'; import { plugin } from '../src/index.js'; describe('TypeScript', () => { @@ -4024,3 +4025,152 @@ describe('TypeScript', () => { `); }); }); + +describe('getUsedTypes', () => { + const schema = buildSchema(/* GraphQL */ ` + type Author { + id: ID! + name: String + books(sort: Sort = null): [Book!]! + } + + enum Sort { + Trending + Popular + Recent + } + + type Book { + id: ID! + title: String + category: Category + } + + enum Category { + Fiction + NonFiction + } + + union SearchResult = Author | Book + + type Query { + search(text: String): [SearchResult!]! + searchBooks(title: String!, category: Category = null, sort: Sort = null): [Book!]! + searchAuthors(name: String!): [Author!]! + } + + input AuthorInput { + name: String + books: [BookInput!] + } + + input BookInput { + title: String + category: Category + } + + type Mutation { + createAuthor(author: AuthorInput!): [Author!]! + } + `); + + it('Should expand fragment', () => { + const document = parse(/* GraphQL */ ` + fragment SearchResult on SearchResult { + ... on Book { + title + category + } + ... on Author { + name + } + } + + query Search($text: String!) { + search(text: $text) { + ...SearchResult + } + } + `); + + const errors = validate(schema, document); + if (errors.length) throw errors; + + const result = getUsedTypeNames(schema, [{ location: 'test-file.ts', document }]); + expect(result).toEqual(new Set(['Category'])); + }); + + it('Should expand inline selection set', () => { + const document = parse(/* GraphQL */ ` + query Search($text: String!) { + search(text: $text) { + ... on Book { + title + category + } + ... on Author { + name + } + } + } + `); + + const errors = validate(schema, document); + if (errors.length) throw errors; + + const result = getUsedTypeNames(schema, [{ location: 'test-file.ts', document }]); + expect(result).toEqual(new Set(['Category'])); + }); + + it('Should expand nested inline selection sets', () => { + const document = parse(/* GraphQL */ ` + query Search($name: String!) { + searchAuthors(name: $name) { + name + books { + title + category + } + } + } + `); + + const errors = validate(schema, document); + if (errors.length) throw errors; + + const result = getUsedTypeNames(schema, [{ location: 'test-file.ts', document }]); + expect(result).toEqual(new Set(['Category'])); + }); + + it('Should expand nested input types', () => { + const document = parse(/* GraphQL */ ` + mutation CreateAuthor($author: AuthorInput!) { + createAuthor(author: $author) { + id + } + } + `); + + const errors = validate(schema, document); + if (errors.length) throw errors; + + const result = getUsedTypeNames(schema, [{ location: 'test-file.ts', document }]); + expect(result).toEqual(new Set(['AuthorInput', 'BookInput', 'Category'])); + }); + + it('Should ignore unused arguments', () => { + const document = parse(/* GraphQL */ ` + query SearchPopularBooks($title: String!) { + searchBooks(title: $title, sort: Popular) { + id + } + } + `); + + const errors = validate(schema, document); + if (errors.length) throw errors; + + const result = getUsedTypeNames(schema, [{ location: 'test-file.ts', document }]); + expect(result).toEqual(new Set([])); + }); +}); diff --git a/packages/utils/plugins-helpers/src/utils.ts b/packages/utils/plugins-helpers/src/utils.ts index 3da8f6b40bb..1f73c720a89 100644 --- a/packages/utils/plugins-helpers/src/utils.ts +++ b/packages/utils/plugins-helpers/src/utils.ts @@ -1,4 +1,12 @@ -import { GraphQLList, GraphQLNamedType, GraphQLNonNull, GraphQLOutputType, isListType, isNonNullType } from 'graphql'; +import { + GraphQLInputType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, + GraphQLOutputType, + isListType, + isNonNullType, +} from 'graphql'; import { Types } from './types.js'; export function mergeOutputs(content: Types.PluginOutput | Array): string { @@ -19,11 +27,11 @@ export function mergeOutputs(content: Types.PluginOutput | Array | GraphQLList { +export function isWrapperType(t: GraphQLInputType | GraphQLOutputType): t is GraphQLNonNull | GraphQLList { return isListType(t) || isNonNullType(t); } -export function getBaseType(type: GraphQLOutputType): GraphQLNamedType { +export function getBaseType(type: GraphQLInputType | GraphQLOutputType): GraphQLNamedType { if (isWrapperType(type)) { return getBaseType(type.ofType); } diff --git a/website/public/config.schema.json b/website/public/config.schema.json index 9478ddf73f2..532bb5ad624 100644 --- a/website/public/config.schema.json +++ b/website/public/config.schema.json @@ -534,7 +534,7 @@ "type": "boolean" }, "onlyOperationTypes": { - "description": "This will cause the generator to emit types for operations only (basically only enums and scalars).\nInteracts well with `preResolveTypes: true`\nDefault value: \"false\"", + "description": "This will cause the generator to only emit types used by one or more operations (basically only enums, inputs, and scalars).\nInteracts well with `preResolveTypes: true`\nDefault value: \"false\"", "type": "boolean" }, "immutableTypes": { @@ -654,6 +654,14 @@ "emitLegacyCommonJSImports": { "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" + }, + "extractAllFieldsToTypes": { + "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", + "type": "boolean" + }, + "printFieldsOnNewLines": { + "description": "If you prefer to have each field in generated types printed on a new line, set this to true.\nThis can be useful for improving readability of the resulting types,\nwithout resorting to running tools like Prettier on the output.\nDefault value: \"false\"", + "type": "boolean" } } }, @@ -830,6 +838,14 @@ "emitLegacyCommonJSImports": { "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" + }, + "extractAllFieldsToTypes": { + "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", + "type": "boolean" + }, + "printFieldsOnNewLines": { + "description": "If you prefer to have each field in generated types printed on a new line, set this to true.\nThis can be useful for improving readability of the resulting types,\nwithout resorting to running tools like Prettier on the output.\nDefault value: \"false\"", + "type": "boolean" } } }, @@ -1632,6 +1648,14 @@ "emitLegacyCommonJSImports": { "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" + }, + "extractAllFieldsToTypes": { + "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", + "type": "boolean" + }, + "printFieldsOnNewLines": { + "description": "If you prefer to have each field in generated types printed on a new line, set this to true.\nThis can be useful for improving readability of the resulting types,\nwithout resorting to running tools like Prettier on the output.\nDefault value: \"false\"", + "type": "boolean" } } }, @@ -2697,7 +2721,7 @@ "description": "Declares how DocumentNode are created:\n\n- `graphQLTag`: `graphql-tag` or other modules (check `gqlImport`) will be used to generate document nodes. If this is used, document nodes are generated on client side i.e. the module used to generate this will be shipped to the client\n- `documentNode`: document nodes will be generated as objects when we generate the templates.\n- `documentNodeImportFragments`: Similar to documentNode except it imports external fragments instead of embedding them.\n- `external`: document nodes are imported from an external file. To be used with `importDocumentNodeExternallyFrom`\n\nNote that some plugins (like `typescript-graphql-request`) also supports `string` for this parameter.\nDefault value: \"graphQLTag\"" }, "optimizeDocumentNode": { - "description": "If you are using `documentNode: documentMode | documentNodeImportFragments`, you can set this to `true` to apply document optimizations for your GraphQL document.\nThis will remove all \"loc\" and \"description\" fields from the compiled document, and will remove all empty arrays (such as `directives`, `arguments` and `variableDefinitions`).\nDefault value: \"true\"", + "description": "If you are using `documentMode: documentNode | documentNodeImportFragments`, you can set this to `true` to apply document optimizations for your GraphQL document.\nThis will remove all \"loc\" and \"description\" fields from the compiled document, and will remove all empty arrays (such as `directives`, `arguments` and `variableDefinitions`).\nDefault value: \"true\"", "type": "boolean" }, "importOperationTypesFrom": { @@ -2753,6 +2777,14 @@ "emitLegacyCommonJSImports": { "description": "Emit legacy common js imports.\nDefault it will be `true` this way it ensure that generated code works with [non-compliant bundlers](https://github.com/dotansimha/graphql-code-generator/issues/8065).\nDefault value: \"true\"", "type": "boolean" + }, + "extractAllFieldsToTypes": { + "description": "Extract all field types to their own types, instead of inlining them.\nThis helps to reduce type duplication, and makes type errors more readable.\nIt can also significantly reduce the size of the generated code, the generation time,\nand the typechecking time.\nDefault value: \"false\"", + "type": "boolean" + }, + "printFieldsOnNewLines": { + "description": "If you prefer to have each field in generated types printed on a new line, set this to true.\nThis can be useful for improving readability of the resulting types,\nwithout resorting to running tools like Prettier on the output.\nDefault value: \"false\"", + "type": "boolean" } } }, @@ -4186,7 +4218,10 @@ "object": { "type": "boolean" }, "inputValue": { "type": "boolean" }, "defaultValue": { "type": "boolean" }, - "resolvers": { "type": "boolean" } + "resolvers": { "type": "boolean" }, + "query": { "type": "boolean" }, + "mutation": { "type": "boolean" }, + "subscription": { "type": "boolean" } } }, "EnumValuesMap": {