UNPKG

@graphql-codegen/typescript-operations

Version:

GraphQL Code Generator plugin for generating TypeScript types for GraphQL queries, mutations, subscriptions and fragments

543 lines (542 loc) • 26.5 kB
import autoBind from 'auto-bind'; import { getNamedType, GraphQLEnumType, GraphQLInputObjectType, GraphQLScalarType, isEnumType, Kind, TypeInfo, visit, visitWithTypeInfo, } from 'graphql'; import { normalizeImportExtension } from '@graphql-codegen/plugin-helpers'; import { BaseDocumentsVisitor, buildTypeImport, convertSchemaEnumToDeclarationBlockString, DeclarationBlock, DEFAULT_INPUT_SCALARS, generateFragmentImportStatement, generateImportStatement, getConfigValue, getEnumsImports, getNodeComment, indent, isNativeNamedType, isOneOfInputObjectType, parseEnumValues, PreResolveTypesProcessor, printTypeScriptMaybeType, SelectionSetToObject, wrapTypeWithModifiers, } from '@graphql-codegen/visitor-plugin-common'; import { TypeScriptOperationVariablesToObject } from './ts-operation-variables-to-object.js'; export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor { _usedSchemaTypes = {}; _needsExactUtilityType = false; _outputPath; constructor(schema, config, documentNode, outputPath) { super(config, { arrayInputCoercion: getConfigValue(config.arrayInputCoercion, true), noExport: getConfigValue(config.noExport, false), immutableTypes: getConfigValue(config.immutableTypes, false), nonOptionalTypename: getConfigValue(config.nonOptionalTypename, false), mergeFragmentTypes: getConfigValue(config.mergeFragmentTypes, false), allowUndefinedQueryVariables: getConfigValue(config.allowUndefinedQueryVariables, false), enumType: getConfigValue(config.enumType, 'string-literal'), ignoreEnumValuesFromSchema: getConfigValue(config.ignoreEnumValuesFromSchema, false), futureProofEnums: getConfigValue(config.futureProofEnums, false), maybeValue: getConfigValue(config.maybeValue, 'T | null'), inputMaybeValue: getConfigValue(config.inputMaybeValue, 'T | null | undefined'), }, schema); this.config.enumValues = parseEnumValues({ schema, mapOrStr: config.enumValues, ignoreEnumValuesFromSchema: config.ignoreEnumValuesFromSchema, naming: { convert: this.config.convert, options: { typesPrefix: this.config.typesPrefix, typesSuffix: this.config.typesSuffix, useTypesPrefix: this.config.enumPrefix, useTypesSuffix: this.config.enumSuffix, }, }, }); this._outputPath = outputPath; autoBind(this); const allFragments = [ ...documentNode.definitions.filter(d => d.kind === Kind.FRAGMENT_DEFINITION).map(fragmentDef => ({ node: fragmentDef, name: fragmentDef.name.value, onType: fragmentDef.typeCondition.name.value, isExternal: false, })), ...(config.externalFragments || []), ]; // Create a combined document that includes operations, internal and external fragments for enum collection const documentWithAllFragments = { ...documentNode, definitions: [ ...documentNode.definitions.filter(d => d.kind !== Kind.FRAGMENT_DEFINITION), ...allFragments.map(f => f.node), ], }; this._usedSchemaTypes = this.collectUsedSchemaTypesToGenerate({ schema, documentNode: documentWithAllFragments, }); const processorConfig = { namespacedImportName: this.config.namespacedImportName, convertName: this.convertName.bind(this), enumPrefix: this.config.enumPrefix, enumSuffix: this.config.enumSuffix, scalars: this.scalars, formatNamedField: ({ name, isOptional }) => { return (this.config.immutableTypes ? `readonly ${name}` : name) + (isOptional ? '?' : ''); }, wrapTypeWithModifiers: (baseType, type) => { return wrapTypeWithModifiers(baseType, type, { wrapOptional: type => printTypeScriptMaybeType({ type, pattern: this.config.maybeValue, }), wrapArray: type => { const listModifier = this.config.immutableTypes ? 'ReadonlyArray' : 'Array'; return `${listModifier}<${type}>`; }, }); }, printFieldsOnNewLines: this.config.printFieldsOnNewLines, }; this.setSelectionSetHandler(new SelectionSetToObject(new PreResolveTypesProcessor(processorConfig), this.scalars, this.schema, this.convertName.bind(this), this.getFragmentSuffix.bind(this), allFragments, this.config)); const enumsNames = Object.keys(schema.getTypeMap()).filter(typeName => isEnumType(schema.getType(typeName))); this.setVariablesTransformer(new TypeScriptOperationVariablesToObject({ avoidOptionals: this.config.avoidOptionals, immutableTypes: this.config.immutableTypes, inputMaybeValue: this.config.inputMaybeValue, }, this.scalars, this.convertName.bind(this), this.config.namespacedImportName, enumsNames, this.config.enumPrefix, this.config.enumSuffix, this.config.enumValues, this.config.arrayInputCoercion)); this._declarationBlockConfig = { ignoreExport: this.config.noExport, enumNameValueSeparator: ' =', }; } EnumTypeDefinition(node) { const enumName = node.name.value; if (!this._usedSchemaTypes[enumName] || // If not used... this.config.importSchemaTypesFrom // ... Or, is imported from a shared file ) { return null; // ... then, don't generate in this file } return convertSchemaEnumToDeclarationBlockString({ schema: this._schema, node, declarationBlockConfig: this._declarationBlockConfig, enumName, enumValues: this.config.enumValues, futureProofEnums: this.config.futureProofEnums, ignoreEnumValuesFromSchema: this.config.ignoreEnumValuesFromSchema, outputType: this.config.enumType, naming: { convert: this.config.convert, options: { typesPrefix: this.config.typesPrefix, typesSuffix: this.config.typesSuffix, useTypesPrefix: this.config.enumPrefix, useTypesSuffix: this.config.enumSuffix, }, }, }); } InputObjectTypeDefinition(node) { const inputTypeName = node.name.value; if (!this._usedSchemaTypes[inputTypeName] || // If not used... this.config.importSchemaTypesFrom // ... Or, is imported from a shared file ) { return null; // ... then, don't generate in this file } // Note: we usually don't need to export this type, // however, it's not possible to know if another file is using this type e.g. using `importSchemaTypesFrom`, // so it's better export the types. if (isOneOfInputObjectType(this._schema.getType(inputTypeName))) { return new DeclarationBlock(this._declarationBlockConfig) .export() .asKind(this.config.declarationKind.input) .withName(this.convertName(node)) .withComment(node.description?.value) .withContent(`\n` + (node.fields || []).join('\n |')).string; } return new DeclarationBlock(this._declarationBlockConfig) .export() .asKind(this.config.declarationKind.input) .withName(this.convertName(node)) .withComment(node.description?.value) .withBlock((node.fields || []).join('\n')).string; } InputValueDefinition(node, _key, _parent, _path, ancestors) { const oneOfDetails = parseOneOfInputValue({ node, schema: this._schema, ancestors, }); // 1. Flatten GraphQL type nodes to make it easier to turn into string // GraphQL type nodes may have `NonNullType` type before each `ListType` or `NamedType` // This make it a bit harder to know whether a `ListType` or `Namedtype` is nullable without looking at the node before it. // Flattening it into an array where the nullability is in `ListType` and `NamedType` makes it easier to code, // // So, we recursively call `collectAndFlattenTypeNodes` to handle the following scenarios: // - [Thing] // - [Thing!] // - [Thing]! // - [Thing!]! const typeNodes = []; collectAndFlattenTypeNodes({ currentTypeNode: node.type, isPreviousNodeNonNullable: oneOfDetails.isOneOfInputValue, // If the InputValue is part of @oneOf input, we treat it as non-null (even if it must be null in the schema) typeNodes, }); // 2. Generate the type of a TypeScript field declaration // e.g. `field?: string`, then the `string` is the `typePart` let typePart = ''; // We call `.reverse()` here to get the base type node first for (const typeNode of typeNodes.reverse()) { if (typeNode.type === 'NamedType') { const usedInputType = this._usedSchemaTypes[typeNode.name]; if (!usedInputType) { continue; } typePart = usedInputType.tsType; // If the schema is correct, when reversing typeNodes, the first node would be `NamedType`, which means we can safely set it as the base for typePart if (!typeNode.isNonNullable) { typePart = printTypeScriptMaybeType({ type: typePart, pattern: this.config.inputMaybeValue, }); } continue; } if (typeNode.type === 'ListType') { typePart = `Array<${typePart}>`; if (!typeNode.isNonNullable) { typePart = printTypeScriptMaybeType({ type: typePart, pattern: this.config.inputMaybeValue, }); } } } const addOptionalSign = !oneOfDetails.isOneOfInputValue && !this.config.avoidOptionals.inputValue && (node.type.kind !== Kind.NON_NULL_TYPE || (!this.config.avoidOptionals.defaultValue && node.defaultValue !== undefined)); // 3. Generate the keyPart of the TypeScript field declaration // e.g. `field?: string`, then the `field?` is the `keyPart` const keyPart = `${node.name.value}${addOptionalSign ? '?' : ''}`; // 4. other parts of TypeScript field declaration const commentPart = getNodeComment(node); const readonlyPart = this.config.immutableTypes ? 'readonly ' : ''; const currentInputValue = commentPart + indent(`${readonlyPart}${keyPart}: ${typePart};`); // 5. Check if field is part of `@oneOf` input type // If yes, we must generate a union member where the current inputValue must be provieded, and the others are not // e.g. // ```graphql // input UserInput { // byId: ID // byEmail: String // byLegacyId: ID // } // ``` // // Then, the generated type is: // ```ts // type UserInput = // | { byId: string | number; byEmail?: never; byLegacyId?: never } // | { byId?: never; byEmail: string; byLegacyId?: never } // | { byId?: never; byEmail?: never; byLegacyId: string | number } // ``` if (oneOfDetails.isOneOfInputValue) { const fieldParts = []; for (const fieldName of Object.keys(oneOfDetails.parentType.getFields())) { if (fieldName === node.name.value) { fieldParts.push(currentInputValue); continue; } fieldParts.push(`${readonlyPart}${fieldName}?: never;`); } return indent(`{ ${fieldParts.join(' ')} }`); } // If field is not part of @oneOf input type, then it's a input value, just return as-is return currentInputValue; } getImports() { return !this.config.globalNamespace && (this.config.inlineFragmentTypes === 'combine' || this.config.inlineFragmentTypes === 'mask') ? this.config.fragmentImports.map(fragmentImport => generateFragmentImportStatement(fragmentImport, 'type')) : []; } getExternalSchemaTypeImports() { if (!this.config.importSchemaTypesFrom) { return []; } const hasTypesToImport = Object.values(this._usedSchemaTypes).filter(value => value.type === 'GraphQLEnumType' || value.type === 'GraphQLInputObjectType').length > 0; if (!hasTypesToImport) { return []; } return [ generateImportStatement({ baseDir: process.cwd(), baseOutputDir: '', outputPath: this._outputPath, importSource: { path: this.config.importSchemaTypesFrom, namespace: this.config.namespacedImportName, identifiers: [], }, typesImport: true, emitLegacyCommonJSImports: this.config.emitLegacyCommonJSImports, importExtension: normalizeImportExtension({ emitLegacyCommonJSImports: this.config.emitLegacyCommonJSImports, importExtension: this.config.importExtension, }), }), ]; } getEnumsImports() { const usedEnumMap = {}; for (const [enumName, enumDetails] of Object.entries(this.config.enumValues)) { if (this._usedSchemaTypes[enumName]) { usedEnumMap[enumName] = enumDetails; } } return getEnumsImports({ enumValues: usedEnumMap, useTypeImports: this.config.useTypeImports, }); } getScalarsImports() { const fileType = this.config.importSchemaTypesFrom ? 'multi-file-operation-file' : this.config.generateOperationTypes ? 'single-file-operation-file' : 'multi-file-shared-type-file'; const imports = {}; for (const [scalarName, parsedScalar] of Object.entries(this.config.scalars)) { const usedScalar = this._usedSchemaTypes[scalarName]; if (!usedScalar || usedScalar.type !== 'GraphQLScalarType') { continue; } if (parsedScalar.input.isExternal && (((usedScalar.useCases.input || usedScalar.useCases.variables) && fileType === 'single-file-operation-file') || (usedScalar.useCases.input && fileType === 'multi-file-shared-type-file') || (usedScalar.useCases.variables && fileType === 'multi-file-operation-file'))) { imports[parsedScalar.input.source] ||= { identifiers: {} }; imports[parsedScalar.input.source].identifiers[parsedScalar.input.import] = { asDefault: parsedScalar.input.default, }; } if (parsedScalar.output.isExternal && usedScalar.useCases.output && (fileType === 'single-file-operation-file' || fileType === 'multi-file-operation-file')) { imports[parsedScalar.output.source] ||= { identifiers: {} }; imports[parsedScalar.output.source].identifiers[parsedScalar.output.import] = { asDefault: parsedScalar.output.default, }; } } return Object.entries(imports).reduce((res, [importSource, importParams]) => { // One import statement cannot have multiple defaults. // So: // - split each defaults into its own statements // - the named imports can all go together, tracked by `namedImports` const namedImports = []; for (const [identifier, identifierMetadata] of Object.entries(importParams.identifiers)) { if (identifierMetadata.asDefault) { res.push(buildTypeImport({ identifier, source: importSource, asDefault: true, useTypeImports: this.config.useTypeImports, })); continue; } namedImports.push(identifier); } if (namedImports.length > 0) { res.push(buildTypeImport({ identifier: namedImports.join(', '), source: importSource, asDefault: false, useTypeImports: this.config.useTypeImports, })); } return res; }, []); } getPunctuation(_declarationKind) { return ';'; } applyVariablesWrapper(variablesBlock, operationType) { const extraType = this.config.allowUndefinedQueryVariables && operationType === 'Query' ? ' | undefined' : ''; this._needsExactUtilityType = true; return `Exact<${variablesBlock === '{}' ? `{ [key: string]: never; }` : variablesBlock}>${extraType}`; } collectInnerTypesRecursively({ node, usedSchemaTypes, location, }) { if (node instanceof GraphQLEnumType) { if (usedSchemaTypes[node.name]) { return; } usedSchemaTypes[node.name] = { type: 'GraphQLEnumType', node, tsType: this.convertName(node.name), }; return; } if (node instanceof GraphQLScalarType) { const scalarType = usedSchemaTypes[node.name] || { type: 'GraphQLScalarType', node, tsType: (DEFAULT_INPUT_SCALARS[node.name]?.input || this.config.scalars?.[node.name]?.input.type) ?? 'unknown', useCases: { variables: location === 'variables', input: location === 'input', output: false, }, }; if (scalarType.type !== 'GraphQLScalarType') { throw new Error(`${node.name} has been incorrectly parsed as Scalar. This should not happen.`); } // ensure scalar's useCases is updated to have `useCases.input:true` or `useCases.variables:true`, depending on the use case // this is required because if the scalar has been parsed previously, it may only have `useCases.output:true`, and not `useCases.input:true` or `useCases.variables:true` if (location === 'input') { scalarType.useCases.input = true; } if (location === 'variables') { scalarType.useCases.variables = true; } usedSchemaTypes[node.name] = scalarType; return; } // GraphQLInputObjectType if (usedSchemaTypes[node.name]) { return; } usedSchemaTypes[node.name] = { type: 'GraphQLInputObjectType', node, tsType: this.convertName(node.name), }; const fields = node.getFields(); for (const field of Object.values(fields)) { const fieldType = getNamedType(field.type); this.collectInnerTypesRecursively({ node: fieldType, usedSchemaTypes, location: 'input', }); } } /** * @description collects schema types used in operations: * - used Enums for Variables * - used Scalars for Variables * - used Input for Variables (recursively) * * - used Enums for Result * - used Scalars for Result */ collectUsedSchemaTypesToGenerate({ schema, documentNode, }) { const schemaTypes = schema.getTypeMap(); const usedSchemaTypes = {}; // Collect input enums and input types visit(documentNode, { VariableDefinition: variableDefinitionNode => { visit(variableDefinitionNode, { NamedType: namedTypeNode => { const foundInputType = schemaTypes[namedTypeNode.name.value]; if (foundInputType && (foundInputType instanceof GraphQLInputObjectType || foundInputType instanceof GraphQLScalarType || foundInputType instanceof GraphQLEnumType) && !isNativeNamedType(foundInputType)) { this.collectInnerTypesRecursively({ node: foundInputType, usedSchemaTypes, location: 'variables', }); } }, }); }, }); // Collect output enums const typeInfo = new TypeInfo(schema); visit(documentNode, // AST doesn’t include field types (they are defined in the schema) - only names. // TypeInfo is a stateful helper that tracks typing context while walking the AST // visitWithTypeInfo wires that context into a visitor. visitWithTypeInfo(typeInfo, { Field: () => { const fieldType = typeInfo.getType(); if (fieldType) { const namedType = getNamedType(fieldType); if (namedType instanceof GraphQLEnumType) { usedSchemaTypes[namedType.name] = { type: 'GraphQLEnumType', node: namedType, tsType: this.convertName(namedType.name), }; return; } if (namedType instanceof GraphQLScalarType) { const scalarType = usedSchemaTypes[namedType.name] || { type: 'GraphQLScalarType', node: namedType, tsType: this.convertName(namedType.name), useCases: { variables: false, input: false, output: true }, }; if (scalarType.type !== 'GraphQLScalarType') { throw new Error(`${namedType.name} has been incorrectly parsed as Scalar. This should not happen.`); } // ensure scalar's useCases is updated to have `useCases.output:true` // this is required because if the scalar has been parsed previously, it may only have `useCases.input:true` or `useCases.variables:true`, not `useCases.output:true` scalarType.useCases.output = true; usedSchemaTypes[namedType.name] = scalarType; } } }, })); return usedSchemaTypes; } getExactUtilityType() { if (!this.config.generateOperationTypes || // 1. If we don't generate operation types, definitely do not need `Exact` !this._needsExactUtilityType // 2. Even if we generate operation types, we may not need `Exact` if there's no operations in the documents i.e. only fragments found ) { return null; } return `${internalUtilityTypeWarning}type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };`; } getIncrementalUtilityType() { if (!this.config.generateOperationTypes) { return null; } // Note: `export` here is important for 2 reasons // 1. It is not always used in the rest of the file, so this is a safe way to avoid lint rules (in tsconfig or eslint) complaining it's not used in the current file. // 2. In Client Preset, it is used by fragment-masking.ts, so it needs `export` return `${internalUtilityTypeWarning}export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };`; } } const internalUtilityTypeWarning = '/** Internal type. DO NOT USE DIRECTLY. */\n'; function parseOneOfInputValue({ node, schema, ancestors, }) { const realParentDef = ancestors?.[ancestors.length - 1]; if (realParentDef) { const parentType = schema.getType(realParentDef.name.value); if (isOneOfInputObjectType(parentType)) { if (node.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.'); } return { isOneOfInputValue: true, realParentDef, parentType }; } } return { isOneOfInputValue: false }; } function collectAndFlattenTypeNodes({ currentTypeNode, isPreviousNodeNonNullable, typeNodes, }) { if (currentTypeNode.kind === Kind.NON_NULL_TYPE) { const nextTypeNode = currentTypeNode.type; collectAndFlattenTypeNodes({ currentTypeNode: nextTypeNode, isPreviousNodeNonNullable: true, typeNodes, }); } else if (currentTypeNode.kind === Kind.LIST_TYPE) { typeNodes.push({ type: 'ListType', isNonNullable: isPreviousNodeNonNullable }); const nextTypeNode = currentTypeNode.type; collectAndFlattenTypeNodes({ currentTypeNode: nextTypeNode, isPreviousNodeNonNullable: false, typeNodes, }); } else if (currentTypeNode.kind === Kind.NAMED_TYPE) { typeNodes.push({ type: 'NamedType', isNonNullable: isPreviousNodeNonNullable, name: currentTypeNode.name.value, }); } }