diff --git a/.changeset/mighty-numbers-ring.md b/.changeset/mighty-numbers-ring.md new file mode 100644 index 00000000000..ea29bad02fe --- /dev/null +++ b/.changeset/mighty-numbers-ring.md @@ -0,0 +1,6 @@ +--- +'@graphql-codegen/core': minor +'@graphql-codegen/plugin-helpers': minor +--- + +feat(core): ability to skip some specific validation rules with skipDocumentsValidation option diff --git a/packages/graphql-codegen-core/src/codegen.ts b/packages/graphql-codegen-core/src/codegen.ts index 04a010ba465..bf235b9556c 100644 --- a/packages/graphql-codegen-core/src/codegen.ts +++ b/packages/graphql-codegen-core/src/codegen.ts @@ -4,86 +4,88 @@ import { isComplexPluginOutput, federationSpec, getCachedDocumentNodeFromSchema, + AddToSchemaResult, } from '@graphql-codegen/plugin-helpers'; -import { visit, DefinitionNode, Kind, print, NameNode } from 'graphql'; +import { visit, DefinitionNode, Kind, print, NameNode, specifiedRules } from 'graphql'; import { executePlugin } from './execute-plugin'; -import { checkValidationErrors, validateGraphQlDocuments, Source } from '@graphql-tools/utils'; +import { checkValidationErrors, validateGraphQlDocuments, Source, asArray } from '@graphql-tools/utils'; import { mergeSchemas } from '@graphql-tools/schema'; +import { + getSkipDocumentsValidationOption, + hasFederationSpec, + pickFlag, + prioritize, + shouldValidateDocumentsAgainstSchema, + shouldValidateDuplicateDocuments, +} from './utils'; export async function codegen(options: Types.GenerateOptions): Promise { const documents = options.documents || []; - if (documents.length > 0 && !options.skipDocumentsValidation) { + const skipDocumentsValidation = getSkipDocumentsValidationOption(options); + + if (documents.length > 0 && shouldValidateDuplicateDocuments(skipDocumentsValidation)) { validateDuplicateDocuments(documents); } const pluginPackages = Object.keys(options.pluginMap).map(key => options.pluginMap[key]); - if (!options.schemaAst) { - options.schemaAst = mergeSchemas({ - schemas: [], - typeDefs: [options.schema], - convertExtensions: true, - assumeValid: true, - assumeValidSDL: true, - ...options.config, - } as any); - } - // merged schema with parts added by plugins - let schemaChanged = false; - let schemaAst = pluginPackages.reduce((schemaAst, plugin) => { + const additionalTypeDefs: AddToSchemaResult[] = []; + for (const plugin of pluginPackages) { const addToSchema = typeof plugin.addToSchema === 'function' ? plugin.addToSchema(options.config) : plugin.addToSchema; - if (!addToSchema) { - return schemaAst; + if (addToSchema) { + additionalTypeDefs.push(addToSchema); } + } - return mergeSchemas({ - schemas: [schemaAst], - typeDefs: [addToSchema], - }); - }, options.schemaAst); - - const federationInConfig = pickFlag('federation', options.config); + const federationInConfig: boolean = pickFlag('federation', options.config); const isFederation = prioritize(federationInConfig, false); - if ( - isFederation && - !schemaAst.getDirective('external') && - !schemaAst.getDirective('requires') && - !schemaAst.getDirective('provides') && - !schemaAst.getDirective('key') - ) { - schemaChanged = true; - schemaAst = mergeSchemas({ - schemas: [schemaAst], - typeDefs: [federationSpec], - convertExtensions: true, - assumeValid: true, - assumeValidSDL: true, - } as any); + if (isFederation && !hasFederationSpec(options.schemaAst || options.schema)) { + additionalTypeDefs.push(federationSpec); } - if (schemaChanged) { - options.schema = getCachedDocumentNodeFromSchema(schemaAst); - } - - const skipDocumentValidation = - typeof options.config === 'object' && !Array.isArray(options.config) && options.config.skipDocumentsValidation; - - if (options.schemaAst && documents.length > 0 && !skipDocumentValidation) { + // Use mergeSchemas, only if there is no GraphQLSchema provided or the schema should be extended + const mergeNeeded = !options.schemaAst || additionalTypeDefs.length > 0; + + const schemaInstance = mergeNeeded + ? mergeSchemas({ + // If GraphQLSchema provided, use it + schemas: options.schemaAst ? [options.schemaAst] : [], + // If GraphQLSchema isn't provided but DocumentNode is, use it to get the final GraphQLSchema + typeDefs: options.schemaAst ? additionalTypeDefs : [options.schema, ...additionalTypeDefs], + convertExtensions: true, + assumeValid: true, + assumeValidSDL: true, + ...options.config, + } as any) + : options.schemaAst; + + const schemaDocumentNode = + mergeNeeded || !options.schema ? getCachedDocumentNodeFromSchema(schemaInstance) : options.schema; + + if (schemaInstance && documents.length > 0 && shouldValidateDocumentsAgainstSchema(skipDocumentsValidation)) { + const ignored = ['NoUnusedFragments', 'NoUnusedVariables', 'KnownDirectives']; + if (typeof skipDocumentsValidation === 'object' && skipDocumentsValidation.ignoreRules) { + ignored.push(...asArray(skipDocumentsValidation.ignoreRules)); + } const extraFragments: { importFrom: string; node: DefinitionNode }[] = - options.config && (options.config as any).externalFragments ? (options.config as any).externalFragments : []; - const errors = await validateGraphQlDocuments(options.schemaAst, [ - ...documents, - ...extraFragments.map(f => ({ - location: f.importFrom, - document: { kind: Kind.DOCUMENT, definitions: [f.node] }, - })), - ]); + pickFlag('externalFragments', options.config) || []; + const errors = await validateGraphQlDocuments( + schemaInstance, + [ + ...documents, + ...extraFragments.map(f => ({ + location: f.importFrom, + document: { kind: Kind.DOCUMENT, definitions: [f.node] }, + })), + ], + specifiedRules.filter(rule => !ignored.some(ignoredRule => rule.name.startsWith(ignoredRule))) + ); checkValidationErrors(errors); } @@ -109,8 +111,8 @@ export async function codegen(options: Types.GenerateOptions): Promise { name, config: execConfig, parentConfig: options.config, - schema: options.schema, - schemaAst, + schema: schemaDocumentNode, + schemaAst: schemaInstance, documents: options.documents, outputFilename: options.filename, allPlugins: options.plugins, @@ -268,21 +270,3 @@ function validateDuplicateDocuments(files: Types.DocumentFile[]) { } }); } - -function isObjectMap(obj: any): obj is Types.PluginConfig { - return obj && typeof obj === 'object' && !Array.isArray(obj); -} - -function prioritize(...values: T[]): T { - const picked = values.find(val => typeof val === 'boolean'); - - if (typeof picked !== 'boolean') { - return values[values.length - 1]; - } - - return picked; -} - -function pickFlag(flag: string, config: Types.PluginConfig): boolean | undefined { - return isObjectMap(config) ? (config as any)[flag] : undefined; -} diff --git a/packages/graphql-codegen-core/src/execute-plugin.ts b/packages/graphql-codegen-core/src/execute-plugin.ts index 4f0c1e35924..5be9a807dd2 100644 --- a/packages/graphql-codegen-core/src/execute-plugin.ts +++ b/packages/graphql-codegen-core/src/execute-plugin.ts @@ -10,7 +10,7 @@ export interface ExecutePluginOptions { documents: Types.DocumentFile[]; outputFilename: string; allPlugins: Types.ConfiguredPlugin[]; - skipDocumentsValidation?: boolean; + skipDocumentsValidation?: Types.SkipDocumentsValidationOptions; pluginContext?: { [key: string]: any }; } @@ -20,9 +20,9 @@ export async function executePlugin(options: ExecutePluginOptions, plugin: Codeg `Invalid Custom Plugin "${options.name}"`, ` Plugin ${options.name} does not export a valid JS object with "plugin" function. - + Make sure your custom plugin is written in the following form: - + module.exports = { plugin: (schema, documents, config) => { return 'my-custom-plugin-content'; diff --git a/packages/graphql-codegen-core/src/utils.ts b/packages/graphql-codegen-core/src/utils.ts new file mode 100644 index 00000000000..109c6165f79 --- /dev/null +++ b/packages/graphql-codegen-core/src/utils.ts @@ -0,0 +1,77 @@ +import { Types } from '@graphql-codegen/plugin-helpers'; +import { isDocumentNode } from '@graphql-tools/utils'; +import { DocumentNode, GraphQLSchema, isSchema, Kind } from 'graphql'; + +export function isObjectMap(obj: any): obj is Types.PluginConfig { + return obj && typeof obj === 'object' && !Array.isArray(obj); +} + +export function prioritize(...values: T[]): T { + const picked = values.find(val => typeof val === 'boolean'); + + if (typeof picked !== 'boolean') { + return values[values.length - 1]; + } + + return picked; +} + +export function pickFlag(flag: TKey, config: TConfig): TConfig[TKey] | undefined { + return isObjectMap(config) ? config[flag] : undefined; +} + +export function shouldValidateDuplicateDocuments( + skipDocumentsValidationOption: Types.GenerateOptions['skipDocumentsValidation'] +) { + // If the value is true, skip all + if (skipDocumentsValidationOption === true) { + return false; + } + // If the value is object with the specific flag, only skip this one + if (typeof skipDocumentsValidationOption === 'object' && skipDocumentsValidationOption.skipDuplicateValidation) { + return false; + } + // If the value is falsy or the specific flag is not set, validate + return true; +} + +export function shouldValidateDocumentsAgainstSchema( + skipDocumentsValidationOption: Types.GenerateOptions['skipDocumentsValidation'] +) { + // If the value is true, skip all + if (skipDocumentsValidationOption === true) { + return false; + } + // If the value is object with the specific flag, only skip this one + if (typeof skipDocumentsValidationOption === 'object' && skipDocumentsValidationOption.skipValidationAgainstSchema) { + return false; + } + // If the value is falsy or the specific flag is not set, validate + return true; +} + +export function getSkipDocumentsValidationOption(options: Types.GenerateOptions): Types.SkipDocumentsValidationOptions { + // If the value is set on the root level + if (options.skipDocumentsValidation) { + return options.skipDocumentsValidation; + } + // If the value is set under `config` property + const flagFromConfig: Types.SkipDocumentsValidationOptions = pickFlag('skipDocumentsValidation', options.config); + if (flagFromConfig) { + return flagFromConfig; + } + return false; +} + +const federationDirectives = ['key', 'requires', 'provides', 'external']; + +export function hasFederationSpec(schemaOrAST: GraphQLSchema | DocumentNode) { + if (isSchema(schemaOrAST)) { + return federationDirectives.some(directive => schemaOrAST.getDirective(directive)); + } else if (isDocumentNode(schemaOrAST)) { + return schemaOrAST.definitions.some( + def => def.kind === Kind.DIRECTIVE_DEFINITION && federationDirectives.includes(def.name.value) + ); + } + return false; +} diff --git a/packages/utils/plugins-helpers/src/getCachedDocumentNodeFromSchema.ts b/packages/utils/plugins-helpers/src/getCachedDocumentNodeFromSchema.ts index 8ec2d830ce5..99f8e938302 100644 --- a/packages/utils/plugins-helpers/src/getCachedDocumentNodeFromSchema.ts +++ b/packages/utils/plugins-helpers/src/getCachedDocumentNodeFromSchema.ts @@ -1,13 +1,3 @@ -import { getDocumentNodeFromSchema } from '@graphql-tools/utils'; -import { GraphQLSchema, DocumentNode } from 'graphql'; +import { getDocumentNodeFromSchema, memoize1 } from '@graphql-tools/utils'; -const schemaDocumentNodeCache = new WeakMap(); - -export function getCachedDocumentNodeFromSchema(schema: GraphQLSchema) { - let documentNode = schemaDocumentNodeCache.get(schema); - if (!documentNode) { - documentNode = getDocumentNodeFromSchema(schema); - schemaDocumentNodeCache.set(schema, documentNode); - } - return documentNode; -} +export const getCachedDocumentNodeFromSchema = memoize1(getDocumentNodeFromSchema); diff --git a/packages/utils/plugins-helpers/src/types.ts b/packages/utils/plugins-helpers/src/types.ts index 279cc883708..62869fe6fcc 100644 --- a/packages/utils/plugins-helpers/src/types.ts +++ b/packages/utils/plugins-helpers/src/types.ts @@ -5,6 +5,7 @@ export namespace Types { export interface GenerateOptions { filename: string; plugins: Types.ConfiguredPlugin[]; + // TODO: Remove schemaAst and change schema to GraphQLSchema in the next major version schema: DocumentNode; schemaAst?: GraphQLSchema; documents: Types.DocumentFile[]; @@ -12,7 +13,7 @@ export namespace Types { pluginMap: { [name: string]: CodegenPlugin; }; - skipDocumentsValidation?: boolean; + skipDocumentsValidation?: Types.SkipDocumentsValidationOptions; pluginContext?: { [key: string]: any }; } @@ -535,6 +536,24 @@ export namespace Types { */ beforeAllFileWrite: T; }; + + export type SkipDocumentsValidationOptions = + | { + /** + * @description Allows you to skip specific rules while validating the documents. + * See all the rules; https://github.com/graphql/graphql-js/tree/main/src/validation/rules + */ + ignoreRules?: string[]; + /** + * @description Ignore duplicate documents validation + */ + skipDuplicateValidation?: boolean; + /** + * @description Skip document validation entirely against the schema + */ + skipValidationAgainstSchema?: boolean; + } + | boolean; } export function isComplexPluginOutput(obj: Types.PluginOutput): obj is Types.ComplexPluginOutput { diff --git a/website/docs/getting-started/codegen-config.md b/website/docs/getting-started/codegen-config.md index 1ee40dfa400..b7f49f37c52 100644 --- a/website/docs/getting-started/codegen-config.md +++ b/website/docs/getting-started/codegen-config.md @@ -48,7 +48,7 @@ Here are the supported options that you can define in the config file (see [sour - **`generates` (required)** - A map where the key represents an output path for the generated code and the value represents a set of options which are relevant for that specific file. Below are the possible options that can be specified: - **`generates.plugins` (required)** - A list of plugins to use when generating the file. Templates are also considered as plugins and they can be specified in this section. A full list of supported plugins can be found [here](../plugins/index.md). You can also point to a custom plugin in a local file (see [Custom Plugins](../custom-codegen/index.md)). - + - [**`generates.preset`**](../presets/index.md) - A list of available presets for generated files. Such as [`near-operation-file`](../presets/near-operation-file.md#example), which generates files alongside your documents. - [**`generates.schema`**](schema-field.md#output-file-level) - Same as root `schema`, but applies only for the specific output file. @@ -83,6 +83,14 @@ Here are the supported options that you can define in the config file (see [sour - **`pluckConfig.globalGqlIdentifierName`** - Overrides the name of the default GraphQL name identifier. +- **`skipDocumentsValidation`** - Allows to configure how to validate documents + + - **`skipDocumentsValidation.skipValidationAgainstSchema`** - A flag to disable the validation against the schema + + - **`skipDocumentsValidation.ignoreRules`** - An array of rule names to ignore during the validation. You can find a list of the available rules [here](https://github.com/graphql/graphql-js/tree/main/src/validation/rules. + + - **`skipDocumentsValidation.skipDuplicateValidation`** - A flag to disable the validation for duplicate documents + ## Environment Variables You can use environment variables in your `codegen.yml` file::