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..fb205d94453 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 { @@ -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/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); }