From 5a9f96cde55b310976729d5fc3cc2e7aa98ace31 Mon Sep 17 00:00:00 2001 From: Eddy Nguyen Date: Tue, 4 Aug 2026 20:34:15 +1000 Subject: [PATCH] CODEGEN-952 - Fix missing introspection types when queried (#10905) --- .changeset/famous-rats-laugh.md | 5 + .../typescript/operations/src/index.ts | 53 ++- .../typescript/operations/src/visitor.ts | 20 +- ...documents.query-introspection-enum.spec.ts | 445 ++++++++++++++++++ .../operations/tests/ts-documents.spec.ts | 77 --- 5 files changed, 512 insertions(+), 88 deletions(-) create mode 100644 .changeset/famous-rats-laugh.md create mode 100644 packages/plugins/typescript/operations/tests/ts-documents.query-introspection-enum.spec.ts diff --git a/.changeset/famous-rats-laugh.md b/.changeset/famous-rats-laugh.md new file mode 100644 index 00000000000..dc37a673c05 --- /dev/null +++ b/.changeset/famous-rats-laugh.md @@ -0,0 +1,5 @@ +--- +'@graphql-codegen/typescript-operations': patch +--- + +Fix types when introspection is used and values are not generated or imported correctly diff --git a/packages/plugins/typescript/operations/src/index.ts b/packages/plugins/typescript/operations/src/index.ts index 2a3da5d0802..393f5703df9 100644 --- a/packages/plugins/typescript/operations/src/index.ts +++ b/packages/plugins/typescript/operations/src/index.ts @@ -1,4 +1,10 @@ -import { concatAST, GraphQLSchema, type DocumentNode } from 'graphql'; +import { + concatAST, + GraphQLSchema, + parse, + printIntrospectionSchema, + type DocumentNode, +} from 'graphql'; import { oldVisit, PluginFunction, Types } from '@graphql-codegen/plugin-helpers'; import { transformSchemaAST } from '@graphql-codegen/schema-ast'; import { optimizeOperations } from '@graphql-codegen/visitor-plugin-common'; @@ -61,8 +67,7 @@ export const plugin: PluginFunction< leave: visitor, }); - const operationsDefinitions = operationsResult.definitions; - + const operationsDefinitions: string[] = operationsResult.definitions; if (config.addOperationExport) { for (const d of allDocumentsAST.definitions) { if ('name' in d) { @@ -73,15 +78,34 @@ export const plugin: PluginFunction< } } + // #region generateSchemaTypes + // When Input and Enum appear in Result selection sets, we need to + // generate those types so they can be referred to correctly const schemaTypes = oldVisit(transformSchemaAST(schema, config).ast, { leave: visitor }); + const schemaTypesDefinitions = findTransformedDefinitions(schemaTypes); + // #endregion + + // #region generateIntrospectionTypesDefinitions + // It is possible for queries to refer to enums in introspection: + // - `__TypeKind` + // - `__DirectiveOperation` + // + // In such cases, we need to generate the used introspection types + // so the Result types can refer to them correctly (similar to how we do schema types) + let introspectionTypesDefinitions: string[] = []; + if (visitor.shouldVisitIntrospectionTypes()) { + const introspectionTypes = oldVisit(parse(printIntrospectionSchema(schema)), { + leave: visitor, + }); + introspectionTypesDefinitions = findTransformedDefinitions(introspectionTypes); + } + // #endregion - // IMPORTANT: when a visitor leaves a node with no transformation logic, - // It will leave the node as an object. - // Here, we filter in nodes that have been turned into strings, i.e. they have been transformed - // This way, we do not have to explicitly declare a method for every node type to convert them to null - const schemaTypesDefinitions = schemaTypes.definitions.filter(def => typeof def === 'string'); - - let content = [...schemaTypesDefinitions, ...operationsDefinitions].join('\n'); + let content = [ + ...schemaTypesDefinitions, + ...introspectionTypesDefinitions, + ...operationsDefinitions, + ].join('\n'); if (config.globalNamespace) { content = ` @@ -116,3 +140,12 @@ const semanticToStrict = async (schema: GraphQLSchema): Promise = ); } }; + +// IMPORTANT: when a visitor leaves a node with no transformation logic, +// It will leave the node as an object. +// +// This helper function filters in nodes that have been turned into strings, i.e. they have been transformed +// This way, we do not have to explicitly declare a method for every node type to convert them to null +const findTransformedDefinitions = (visitedResult: any): string[] => { + return visitedResult.definitions.filter(def => typeof def === 'string'); +}; diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 8f8461b0210..203b9911a94 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -1,6 +1,5 @@ import autoBind from 'auto-bind'; import { - EnumTypeDefinitionNode, getNamedType, GraphQLEnumType, GraphQLInputObjectType, @@ -8,11 +7,13 @@ import { InputObjectTypeDefinitionNode, InputValueDefinitionNode, isEnumType, + isIntrospectionType, Kind, TypeInfo, visit, visitWithTypeInfo, type DocumentNode, + type EnumTypeDefinitionNode, type FragmentDefinitionNode, type GraphQLNamedInputType, type GraphQLSchema, @@ -80,6 +81,14 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< > { protected _usedSchemaTypes: UsedSchemaTypes = {}; protected _needsExactUtilityType: boolean = false; + /** + * _usedEnumIntrospectionType is a metadata value + * which tracks whether an introspection type enum (i.e. __TypeKind or __DirectiveLocation) + * has been referred to in selection sets + * + * If it is, we need to generate the enum values from introspection types + */ + protected _usedEnumIntrospectionType: boolean = false; private _outputPath: string; constructor( @@ -687,6 +696,11 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< node: namedType, tsType: this.convertName(namedType.name), }; + + if (isIntrospectionType(namedType)) { + this._usedEnumIntrospectionType = true; + } + return; } @@ -739,6 +753,10 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< // 2. In Client Preset, it is used by fragment-masking.ts, so it needs `export` return `${internalUtilityTypeWarning}export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };`; } + + shouldVisitIntrospectionTypes(): boolean { + return this._usedEnumIntrospectionType; + } } const internalUtilityTypeWarning = '/** Internal type. DO NOT USE DIRECTLY. */\n'; diff --git a/packages/plugins/typescript/operations/tests/ts-documents.query-introspection-enum.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.query-introspection-enum.spec.ts new file mode 100644 index 00000000000..c1d74596917 --- /dev/null +++ b/packages/plugins/typescript/operations/tests/ts-documents.query-introspection-enum.spec.ts @@ -0,0 +1,445 @@ +import { buildSchema, parse, versionInfo } from 'graphql'; +import { mergeOutputs } from '@graphql-codegen/plugin-helpers'; +import { validateTs } from '@graphql-codegen/testing'; +import { plugin } from '../src/index.js'; + +if (versionInfo.major === 15) { + describe('TypeScript Operations Plugin - Query introspection enums graphql@16', () => { + it('should handle introspection types (__schema)', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + type Post { + title: String + } + type Query { + post: Post! + } + `); + const query = parse(/* GraphQL */ ` + query Info { + __schema { + directives { + locations + } + } + } + `); + + const result = mergeOutputs([ + await plugin(testSchema, [{ document: query }], {}, { outputFile: '' }), + ]); + + expect(result).toMatchInlineSnapshot(` + "/** Internal type. DO NOT USE DIRECTLY. */ + type Exact = { [K in keyof T]: T[K] }; + /** Internal type. DO NOT USE DIRECTLY. */ + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. */ + export type __DirectiveLocation = + /** Location adjacent to a query operation. */ + | 'QUERY' + /** Location adjacent to a mutation operation. */ + | 'MUTATION' + /** Location adjacent to a subscription operation. */ + | 'SUBSCRIPTION' + /** Location adjacent to a field. */ + | 'FIELD' + /** Location adjacent to a fragment definition. */ + | 'FRAGMENT_DEFINITION' + /** Location adjacent to a fragment spread. */ + | 'FRAGMENT_SPREAD' + /** Location adjacent to an inline fragment. */ + | 'INLINE_FRAGMENT' + /** Location adjacent to a variable definition. */ + | 'VARIABLE_DEFINITION' + /** Location adjacent to a schema definition. */ + | 'SCHEMA' + /** Location adjacent to a scalar definition. */ + | 'SCALAR' + /** Location adjacent to an object type definition. */ + | 'OBJECT' + /** Location adjacent to a field definition. */ + | 'FIELD_DEFINITION' + /** Location adjacent to an argument definition. */ + | 'ARGUMENT_DEFINITION' + /** Location adjacent to an interface definition. */ + | 'INTERFACE' + /** Location adjacent to a union definition. */ + | 'UNION' + /** Location adjacent to an enum definition. */ + | 'ENUM' + /** Location adjacent to an enum value definition. */ + | 'ENUM_VALUE' + /** Location adjacent to an input object type definition. */ + | 'INPUT_OBJECT' + /** Location adjacent to an input object field definition. */ + | 'INPUT_FIELD_DEFINITION'; + + export type InfoQueryVariables = Exact<{ [key: string]: never; }>; + + + export type InfoQuery = { __schema: { directives: Array<{ locations: Array<__DirectiveLocation> }> } }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + + it('should handle introspection types (__type)', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + type Post { + title: String + } + type Query { + post: Post! + } + `); + const query = parse(/* GraphQL */ ` + query Info { + __type(name: "Post") { + name + fields { + name + type { + name + kind + } + } + } + } + `); + + const result = mergeOutputs([ + await plugin(testSchema, [{ document: query }], {}, { outputFile: '' }), + ]); + + expect(result).toMatchInlineSnapshot(` + "/** Internal type. DO NOT USE DIRECTLY. */ + type Exact = { [K in keyof T]: T[K] }; + /** Internal type. DO NOT USE DIRECTLY. */ + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** An enum describing what kind of type a given \`__Type\` is. */ + export type __TypeKind = + /** Indicates this type is a scalar. */ + | 'SCALAR' + /** Indicates this type is an object. \`fields\` and \`interfaces\` are valid fields. */ + | 'OBJECT' + /** Indicates this type is an interface. \`fields\`, \`interfaces\`, and \`possibleTypes\` are valid fields. */ + | 'INTERFACE' + /** Indicates this type is a union. \`possibleTypes\` is a valid field. */ + | 'UNION' + /** Indicates this type is an enum. \`enumValues\` is a valid field. */ + | 'ENUM' + /** Indicates this type is an input object. \`inputFields\` is a valid field. */ + | 'INPUT_OBJECT' + /** Indicates this type is a list. \`ofType\` is a valid field. */ + | 'LIST' + /** Indicates this type is a non-null. \`ofType\` is a valid field. */ + | 'NON_NULL'; + + export type InfoQueryVariables = Exact<{ [key: string]: never; }>; + + + export type InfoQuery = { __type: { name: string | null, fields: Array<{ name: string, type: { name: string | null, kind: __TypeKind } }> | null } | null }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + }); +} + +if (versionInfo.major === 16) { + describe('TypeScript Operations Plugin - Query introspection enums graphql@16', () => { + it('should handle introspection types (__schema)', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + type Post { + title: String + } + type Query { + post: Post! + } + `); + const query = parse(/* GraphQL */ ` + query Info { + __schema { + directives { + locations + } + } + } + `); + + const result = mergeOutputs([ + await plugin(testSchema, [{ document: query }], {}, { outputFile: '' }), + ]); + + expect(result).toMatchInlineSnapshot(` + "/** Internal type. DO NOT USE DIRECTLY. */ + type Exact = { [K in keyof T]: T[K] }; + /** Internal type. DO NOT USE DIRECTLY. */ + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. */ + export type __DirectiveLocation = + /** Location adjacent to a query operation. */ + | 'QUERY' + /** Location adjacent to a mutation operation. */ + | 'MUTATION' + /** Location adjacent to a subscription operation. */ + | 'SUBSCRIPTION' + /** Location adjacent to a field. */ + | 'FIELD' + /** Location adjacent to a fragment definition. */ + | 'FRAGMENT_DEFINITION' + /** Location adjacent to a fragment spread. */ + | 'FRAGMENT_SPREAD' + /** Location adjacent to an inline fragment. */ + | 'INLINE_FRAGMENT' + /** Location adjacent to a variable definition. */ + | 'VARIABLE_DEFINITION' + /** Location adjacent to a schema definition. */ + | 'SCHEMA' + /** Location adjacent to a scalar definition. */ + | 'SCALAR' + /** Location adjacent to an object type definition. */ + | 'OBJECT' + /** Location adjacent to a field definition. */ + | 'FIELD_DEFINITION' + /** Location adjacent to an argument definition. */ + | 'ARGUMENT_DEFINITION' + /** Location adjacent to an interface definition. */ + | 'INTERFACE' + /** Location adjacent to a union definition. */ + | 'UNION' + /** Location adjacent to an enum definition. */ + | 'ENUM' + /** Location adjacent to an enum value definition. */ + | 'ENUM_VALUE' + /** Location adjacent to an input object type definition. */ + | 'INPUT_OBJECT' + /** Location adjacent to an input object field definition. */ + | 'INPUT_FIELD_DEFINITION' + /** Location adjacent to a directive definition. */ + | 'DIRECTIVE_DEFINITION'; + + export type InfoQueryVariables = Exact<{ [key: string]: never; }>; + + + export type InfoQuery = { __schema: { directives: Array<{ locations: Array<__DirectiveLocation> }> } }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + + it('should handle introspection types (__type)', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + type Post { + title: String + } + type Query { + post: Post! + } + `); + const query = parse(/* GraphQL */ ` + query Info { + __type(name: "Post") { + name + fields { + name + type { + name + kind + } + } + } + } + `); + + const result = mergeOutputs([ + await plugin(testSchema, [{ document: query }], {}, { outputFile: '' }), + ]); + + expect(result).toMatchInlineSnapshot(` + "/** Internal type. DO NOT USE DIRECTLY. */ + type Exact = { [K in keyof T]: T[K] }; + /** Internal type. DO NOT USE DIRECTLY. */ + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** An enum describing what kind of type a given \`__Type\` is. */ + export type __TypeKind = + /** Indicates this type is a scalar. */ + | 'SCALAR' + /** Indicates this type is an object. \`fields\` and \`interfaces\` are valid fields. */ + | 'OBJECT' + /** Indicates this type is an interface. \`fields\`, \`interfaces\`, and \`possibleTypes\` are valid fields. */ + | 'INTERFACE' + /** Indicates this type is a union. \`possibleTypes\` is a valid field. */ + | 'UNION' + /** Indicates this type is an enum. \`enumValues\` is a valid field. */ + | 'ENUM' + /** Indicates this type is an input object. \`inputFields\` is a valid field. */ + | 'INPUT_OBJECT' + /** Indicates this type is a list. \`ofType\` is a valid field. */ + | 'LIST' + /** Indicates this type is a non-null. \`ofType\` is a valid field. */ + | 'NON_NULL'; + + export type InfoQueryVariables = Exact<{ [key: string]: never; }>; + + + export type InfoQuery = { __type: { name: string | null, fields: Array<{ name: string, type: { name: string | null, kind: __TypeKind } }> | null } | null }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + }); +} + +if (versionInfo.major === 17) { + describe('TypeScript Operations Plugin - Query introspection enums graphql@17', () => { + it('should handle introspection types (__schema)', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + type Post { + title: String + } + type Query { + post: Post! + } + `); + const query = parse(/* GraphQL */ ` + query Info { + __schema { + directives { + locations + } + } + } + `); + + const result = mergeOutputs([ + await plugin(testSchema, [{ document: query }], {}, { outputFile: '' }), + ]); + + expect(result).toMatchInlineSnapshot(` + "/** Internal type. DO NOT USE DIRECTLY. */ + type Exact = { [K in keyof T]: T[K] }; + /** Internal type. DO NOT USE DIRECTLY. */ + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. */ + export type __DirectiveLocation = + /** Location adjacent to a query operation. */ + | 'QUERY' + /** Location adjacent to a mutation operation. */ + | 'MUTATION' + /** Location adjacent to a subscription operation. */ + | 'SUBSCRIPTION' + /** Location adjacent to a field. */ + | 'FIELD' + /** Location adjacent to a fragment definition. */ + | 'FRAGMENT_DEFINITION' + /** Location adjacent to a fragment spread. */ + | 'FRAGMENT_SPREAD' + /** Location adjacent to an inline fragment. */ + | 'INLINE_FRAGMENT' + /** Location adjacent to an operation variable definition. */ + | 'VARIABLE_DEFINITION' + /** Location adjacent to a fragment variable definition. */ + | 'FRAGMENT_VARIABLE_DEFINITION' + /** Location adjacent to a schema definition. */ + | 'SCHEMA' + /** Location adjacent to a scalar definition. */ + | 'SCALAR' + /** Location adjacent to an object type definition. */ + | 'OBJECT' + /** Location adjacent to a field definition. */ + | 'FIELD_DEFINITION' + /** Location adjacent to an argument definition. */ + | 'ARGUMENT_DEFINITION' + /** Location adjacent to an interface definition. */ + | 'INTERFACE' + /** Location adjacent to a union definition. */ + | 'UNION' + /** Location adjacent to an enum definition. */ + | 'ENUM' + /** Location adjacent to an enum value definition. */ + | 'ENUM_VALUE' + /** Location adjacent to an input object type definition. */ + | 'INPUT_OBJECT' + /** Location adjacent to an input object field definition. */ + | 'INPUT_FIELD_DEFINITION' + /** Location adjacent to a directive definition. */ + | 'DIRECTIVE_DEFINITION'; + + export type InfoQueryVariables = Exact<{ [key: string]: never; }>; + + + export type InfoQuery = { __schema: { directives: Array<{ locations: Array<__DirectiveLocation> }> } }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + + it('should handle introspection types (__type)', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + type Post { + title: String + } + type Query { + post: Post! + } + `); + const query = parse(/* GraphQL */ ` + query Info { + __type(name: "Post") { + name + fields { + name + type { + name + kind + } + } + } + } + `); + + const result = mergeOutputs([ + await plugin(testSchema, [{ document: query }], {}, { outputFile: '' }), + ]); + + expect(result).toMatchInlineSnapshot(` + "/** Internal type. DO NOT USE DIRECTLY. */ + type Exact = { [K in keyof T]: T[K] }; + /** Internal type. DO NOT USE DIRECTLY. */ + export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; + /** An enum describing what kind of type a given \`__Type\` is. */ + export type __TypeKind = + /** Indicates this type is a scalar. */ + | 'SCALAR' + /** Indicates this type is an object. \`fields\` and \`interfaces\` are valid fields. */ + | 'OBJECT' + /** Indicates this type is an interface. \`fields\`, \`interfaces\`, and \`possibleTypes\` are valid fields. */ + | 'INTERFACE' + /** Indicates this type is a union. \`possibleTypes\` is a valid field. */ + | 'UNION' + /** Indicates this type is an enum. \`enumValues\` is a valid field. */ + | 'ENUM' + /** Indicates this type is an input object. \`inputFields\` is a valid field. */ + | 'INPUT_OBJECT' + /** Indicates this type is a list. \`ofType\` is a valid field. */ + | 'LIST' + /** Indicates this type is a non-null. \`ofType\` is a valid field. */ + | 'NON_NULL'; + + export type InfoQueryVariables = Exact<{ [key: string]: never; }>; + + + export type InfoQuery = { __type: { name: string | null, fields: Array<{ name: string, type: { name: string | null, kind: __TypeKind } }> | null } | null }; + " + `); + + validateTs(result, undefined, undefined, undefined, undefined, true); + }); + }); +} diff --git a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts index f642e7aca03..1d98e92f2e5 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts @@ -2907,83 +2907,6 @@ export type Q2Query = { search: Array< `); }); - it('should handle introspection types (__schema)', async () => { - const testSchema = buildSchema(/* GraphQL */ ` - type Post { - title: String - } - type Query { - post: Post! - } - `); - const query = parse(/* GraphQL */ ` - query Info { - __schema { - queryType { - fields { - name - } - } - } - } - `); - - const { content } = await plugin( - testSchema, - [{ location: '', document: query }], - {}, - { outputFile: 'graphql.ts' }, - ); - - expect(content).toMatchInlineSnapshot(` - "export type InfoQueryVariables = Exact<{ [key: string]: never; }>; - - - export type InfoQuery = { __schema: { queryType: { fields: Array<{ name: string }> | null } } }; - " - `); - }); - - it('should handle introspection types (__type)', async () => { - const testSchema = buildSchema(/* GraphQL */ ` - type Post { - title: String - } - type Query { - post: Post! - } - `); - const query = parse(/* GraphQL */ ` - query Info { - __type(name: "Post") { - name - fields { - name - type { - name - kind - } - } - } - } - `); - - const { content } = await plugin( - testSchema, - [{ location: '', document: query }], - {}, - { outputFile: 'graphql.ts' }, - ); - - expect(content).toMatchInlineSnapshot(` - "export type InfoQueryVariables = Exact<{ [key: string]: never; }>; - - - export type InfoQuery = { __type: { name: string | null, fields: Array<{ name: string, type: { name: string | null, kind: __TypeKind } }> | null } | null }; - " - `); - }); - it('Should generate correctly when using enums and typesPrefix', async () => { const testSchema = buildSchema(/* GraphQL */ ` enum Access {