diff --git a/src/config.ts b/src/config.ts index d6ec9f2b..0e1d669b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,8 @@ import type { TypeScriptPluginConfig } from '@graphql-codegen/typescript'; import type { NamingConventionMap } from '@graphql-codegen/visitor-plugin-common'; -export type ValidationSchema = 'yup' | 'zod' | 'myzod' | 'valibot'; +export type ValidationSchema = 'yup' | 'zod' | 'zod/v4' | 'myzod' | 'valibot'; +export type LazyStrategy = 'all' | 'circular' export type ValidationSchemaExportType = 'function' | 'const'; export interface DirectiveConfig { @@ -35,6 +36,23 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ schema?: ValidationSchema + /** + * @description Setting to determine when to set a property to lazy. 'Circular' will only use lazy for circular references. 'All' will set lazy for all properties referencing another schema. + * @default all + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/file.ts: + * plugins: + * - typescript + * - graphql-codegen-validation-schema + * config: + * schema: yup + * lazy: circular + * ``` + */ + lazyStrategy?: LazyStrategy; /** * @description import types from generated typescript type path * if not given, omit import statement. @@ -96,6 +114,50 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ useTypeImports?: boolean + /** + * @description Creates schemas for input types only. + * This gives compatibility with TypeScript's "importsNotUsedAsValues": "error" option + * Should used in conjunction with `importFrom` option. + * @default false + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/types.ts: + * plugins: + * - typescript + * path/to/schemas.ts: + * plugins: + * - graphql-codegen-validation-schema + * config: + * schema: yup + * importFrom: ./path/to/types + * inputsOnly: true + * ``` + */ + inputOnly?: boolean; + /** + * @description Creates schemas for input types only. + * This gives compatibility with TypeScript's "importsNotUsedAsValues": "error" option + * Should used in conjunction with `importFrom` option. + * @default false + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/types.ts: + * plugins: + * - typescript + * path/to/schemas.ts: + * plugins: + * - graphql-codegen-validation-schema + * config: + * schema: yup + * importFrom: ./path/to/types + * inputsOnly: true + * ``` + */ + inputDiscriminator?: string; /** * @description Prefixes all import types from generated typescript type. * @default "" diff --git a/src/index.ts b/src/index.ts index 39dd1e8a..565aaac2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,12 +4,12 @@ import type { ValidationSchemaPluginConfig } from './config.js'; import type { SchemaVisitor } from './types.js'; import { transformSchemaAST } from '@graphql-codegen/schema-ast'; import { buildSchema, printSchema, visit } from 'graphql'; - import { isGeneratedByIntrospection, topologicalSortAST } from './graphql.js'; import { MyZodSchemaVisitor } from './myzod/index.js'; import { ValibotSchemaVisitor } from './valibot/index.js'; import { YupSchemaVisitor } from './yup/index.js'; import { ZodSchemaVisitor } from './zod/index.js'; +import {Zodv4SchemaVisitor} from './zodv4/index.js'; export const plugin: PluginFunction = ( schema: GraphQLSchema, @@ -32,6 +32,8 @@ export const plugin: PluginFunction generateFieldMyZodSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldMyZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -105,7 +108,7 @@ export class MyZodSchemaVisitor extends BaseSchemaVisitor { get ObjectTypeDefinition() { return { - leave: ObjectTypeDefinitionBuilder(this.config.withObjectType, (node: ObjectTypeDefinitionNode) => { + leave: ObjectTypeDefinitionBuilder(this.config.withObjectType && !this.config.inputOnly, (node: ObjectTypeDefinitionNode) => { const visitor = this.createVisitor('output'); const name = visitor.convertName(node.name.value); const typeName = visitor.prefixTypeNamespace(name); @@ -116,7 +119,7 @@ export class MyZodSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = node.fields?.map(field => generateFieldMyZodSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldMyZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -237,7 +240,8 @@ export class MyZodSchemaVisitor extends BaseSchemaVisitor { name: string, ) { const typeName = visitor.prefixTypeNamespace(name); - const shape = fields.map(field => generateFieldMyZodSchema(this.config, visitor, field, 2)).join(',\n'); + const discriminator = this.config.inputDiscriminator ? `\t${this.config.inputDiscriminator}: myzod.literal('${name}'),` : '' + const shape = fields.map(field => generateFieldMyZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -245,7 +249,7 @@ export class MyZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: myzod.Type<${typeName}>`) - .withContent(['myzod.object({', shape, '})'].join('\n')) + .withContent(['myzod.object({', discriminator, shape, '})'].join('\n')) .string; case 'function': @@ -254,30 +258,30 @@ export class MyZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): myzod.Type<${typeName}>`) - .withBlock([indent(`return myzod.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return myzod.object({`), discriminator, shape, indent('})')].join('\n')) .string; } } } -function generateFieldMyZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string { - const gen = generateFieldTypeMyZodSchema(config, visitor, field, field.type); - return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount); +function generateFieldMyZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, circularTypes: Set): string { + const gen = generateFieldTypeMyZodSchema(config, visitor, field, field.type, undefined, circularTypes); + return indent(`${field.name.value}: ${maybeLazy(field.type, gen, config, circularTypes)}`, indentCount); } -function generateFieldTypeMyZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string { +function generateFieldTypeMyZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode, circularTypes: Set): string { if (isListType(type)) { - const gen = generateFieldTypeMyZodSchema(config, visitor, field, type.type, type); + const gen = generateFieldTypeMyZodSchema(config, visitor, field, type.type, type, circularTypes); if (!isNonNullType(parentType)) { - const arrayGen = `myzod.array(${maybeLazy(type.type, gen)})`; + const arrayGen = `myzod.array(${maybeLazy(type.type, gen, config, circularTypes)})`; const maybeLazyGen = applyDirectives(config, field, arrayGen); return `${maybeLazyGen}.optional().nullable()`; } - return `myzod.array(${maybeLazy(type.type, gen)})`; + return `myzod.array(${maybeLazy(type.type, gen, config, circularTypes)})`; } if (isNonNullType(type)) { - const gen = generateFieldTypeMyZodSchema(config, visitor, field, type.type, type); - return maybeLazy(type.type, gen); + const gen = generateFieldTypeMyZodSchema(config, visitor, field, type.type, type, circularTypes); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeMyZodSchema(config, visitor, type.name); @@ -358,9 +362,23 @@ function generateNameNodeMyZodSchema(config: ValidationSchemaPluginConfig, visit } } -function maybeLazy(type: TypeNode, schema: string): string { - if (isNamedType(type) && isInput(type.name.value)) - return `myzod.lazy(() => ${schema})`; +function maybeLazy( + type: TypeNode, + schema: string, + config: ValidationSchemaPluginConfig, + circularTypes: Set +): string { + if (isNamedType(type)) { + const typeName = type.name.value; + + if (config.lazyStrategy === 'all' && isInput(typeName)) { + return `myzod.lazy(() => ${schema})`; + } + + if (config.lazyStrategy === 'circular' && circularTypes.has(typeName)) { + return `myzod.lazy(() => ${schema})`; + } + } return schema; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..44a928a8 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,39 @@ +import { getNamedType, GraphQLNamedType, isObjectType, isInputObjectType } from 'graphql'; + +export function findCircularTypes(schema: GraphQLSchema): Set { + const circular = new Set(); + const visited = new Set(); + const stack = new Set(); + + function visit(typeName: string) { + if (stack.has(typeName)) { + circular.add(typeName); + return; + } + if (visited.has(typeName)) return; + visited.add(typeName); + stack.add(typeName); + + const type = schema.getType(typeName); + if (!type || !(isObjectType(type) || isInputObjectType(type))) { + stack.delete(typeName); + return; + } + + const fields = type.getFields(); + for (const field of Object.values(fields)) { + const fieldType = getNamedType(field.type); + visit(fieldType.name); + } + + stack.delete(typeName); + } + + for (const type of Object.values(schema.getTypeMap())) { + if (!type.name.startsWith('__')) { + visit(type.name); + } + } + + return circular; +} diff --git a/src/valibot/index.ts b/src/valibot/index.ts index 7b9671ba..d1fe39ce 100644 --- a/src/valibot/index.ts +++ b/src/valibot/index.ts @@ -24,10 +24,13 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { findCircularTypes } from '../utils.js'; export class ValibotSchemaVisitor extends BaseSchemaVisitor { constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { super(schema, config); + this.circularTypes = findCircularTypes(schema); + this.config.lazyStrategy ??= 'all'; } importValidationSchema(): string { @@ -66,7 +69,7 @@ export class ValibotSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = node.fields?.map(field => generateFieldValibotSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldValibotSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { default: @@ -85,7 +88,7 @@ export class ValibotSchemaVisitor extends BaseSchemaVisitor { get ObjectTypeDefinition() { return { - leave: ObjectTypeDefinitionBuilder(this.config.withObjectType, (node: ObjectTypeDefinitionNode) => { + leave: ObjectTypeDefinitionBuilder(this.config.withObjectType && !this.config.inputOnly, (node: ObjectTypeDefinitionNode) => { const visitor = this.createVisitor('output'); const name = visitor.convertName(node.name.value); const typeName = visitor.prefixTypeNamespace(name); @@ -96,7 +99,7 @@ export class ValibotSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = node.fields?.map(field => generateFieldValibotSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldValibotSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { default: @@ -189,7 +192,8 @@ export class ValibotSchemaVisitor extends BaseSchemaVisitor { name: string, ) { const typeName = visitor.prefixTypeNamespace(name); - const shape = fields.map(field => generateFieldValibotSchema(this.config, visitor, field, 2)).join(',\n'); + const discriminator = this.config.inputDiscriminator ? `\t${this.config.inputDiscriminator}: v.literal('${name}'),` : '' + const shape = fields.map(field => generateFieldValibotSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { default: @@ -197,29 +201,29 @@ export class ValibotSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): v.GenericSchema<${typeName}>`) - .withBlock([indent(`return v.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return v.object({`), discriminator, shape, indent('})')].join('\n')) .string; } } } -function generateFieldValibotSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string { - const gen = generateFieldTypeValibotSchema(config, visitor, field, field.type); - return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount); +function generateFieldValibotSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, circularTypes: Set): string { + const gen = generateFieldTypeValibotSchema(config, visitor, field, field.type, undefined, circularTypes); + return indent(`${field.name.value}: ${maybeLazy(field.type, gen, config, circularTypes)}`, indentCount); } -function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string { +function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode, circularTypes: Set): string { if (isListType(type)) { - const gen = generateFieldTypeValibotSchema(config, visitor, field, type.type, type); - const arrayGen = `v.array(${maybeLazy(type.type, gen)})`; + const gen = generateFieldTypeValibotSchema(config, visitor, field, type.type, type, circularTypes); + const arrayGen = `v.array(${maybeLazy(type.type, gen, config, circularTypes)})`; if (!isNonNullType(parentType)) return `v.nullish(${arrayGen})`; return arrayGen; } if (isNonNullType(type)) { - const gen = generateFieldTypeValibotSchema(config, visitor, field, type.type, type); - return maybeLazy(type.type, gen); + const gen = generateFieldTypeValibotSchema(config, visitor, field, type.type, type, circularTypes); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeValibotSchema(config, visitor, type.name); @@ -283,9 +287,24 @@ function generateNameNodeValibotSchema(config: ValidationSchemaPluginConfig, vis } } -function maybeLazy(type: TypeNode, schema: string): string { - if (isNamedType(type) && isInput(type.name.value)) - return `v.lazy(() => ${schema})`; +function maybeLazy( + type: TypeNode, + schema: string, + config: ValidationSchemaPluginConfig, + circularTypes: Set +): string { + if (isNamedType(type)) { + // https://github.com/jquense/yup/issues/1283#issuecomment-786559444 + const typeName = type.name.value; + + if (config.lazyStrategy === 'all' && isInput(typeName)) { + return `v.lazy(() => ${schema})`; + } + + if (config.lazyStrategy === 'circular' && circularTypes.has(typeName)) { + return `v.lazy(() => ${schema})`; + } + } return schema; } diff --git a/src/yup/index.ts b/src/yup/index.ts index 7370e9ea..3084dfc9 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -29,10 +29,13 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { findCircularTypes } from '../utils.js'; export class YupSchemaVisitor extends BaseSchemaVisitor { constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { super(schema, config); + this.circularTypes = findCircularTypes(schema); + this.config.lazyStrategy ??= 'all'; } importValidationSchema(): string { @@ -85,7 +88,7 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { // Building schema for fields. const shape = node.fields?.map((field) => { - const fieldSchema = generateFieldYupSchema(this.config, visitor, field, 2); + const fieldSchema = generateFieldYupSchema(this.config, visitor, field, 2, this.circularTypes); return isNonNullType(field.type) ? fieldSchema : `${fieldSchema}.optional()`; }).join(',\n'); @@ -117,7 +120,7 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { get ObjectTypeDefinition() { return { - leave: ObjectTypeDefinitionBuilder(this.config.withObjectType, (node: ObjectTypeDefinitionNode) => { + leave: ObjectTypeDefinitionBuilder(this.config.withObjectType && !this.config.inputOnly, (node: ObjectTypeDefinitionNode) => { const visitor = this.createVisitor('output'); const name = visitor.convertName(node.name.value); const typeName = visitor.prefixTypeNamespace(name); @@ -128,7 +131,7 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = shapeFields(node.fields, this.config, visitor); + const shape = shapeFields(node.fields, this.config, visitor, this.circularTypes); switch (this.config.validationSchemaExportType) { case 'const': @@ -256,7 +259,8 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { name: string, ) { const typeName = visitor.prefixTypeNamespace(name); - const shape = shapeFields(fields, this.config, visitor); + const discriminator = this.config.inputDiscriminator ? `\t${this.config.inputDiscriminator}: yup.string().oneOf(['${name}']).defined(),` : ''; + const shape = shapeFields(fields, this.config, visitor, this.circularTypes); switch (this.config.validationSchemaExportType) { case 'const': @@ -264,7 +268,7 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: yup.ObjectSchema<${typeName}>`) - .withContent(['yup.object({', shape, '})'].join('\n')) + .withContent(['yup.object({', discriminator, shape, '})'].join('\n')) .string; case 'function': @@ -273,16 +277,16 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): yup.ObjectSchema<${typeName}>`) - .withBlock([indent(`return yup.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return yup.object({`), discriminator, shape, indent('})')].join('\n')) .string; } } } -function shapeFields(fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[] | undefined, config: ValidationSchemaPluginConfig, visitor: Visitor) { +function shapeFields(fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[] | undefined, config: ValidationSchemaPluginConfig, visitor: Visitor, circularTypes: Set) { return fields ?.map((field) => { - let fieldSchema = generateFieldYupSchema(config, visitor, field, 2); + let fieldSchema = generateFieldYupSchema(config, visitor, field, 2, circularTypes); if (field.kind === Kind.INPUT_VALUE_DEFINITION) { const { defaultValue } = field; @@ -318,26 +322,26 @@ function shapeFields(fields: readonly (FieldDefinitionNode | InputValueDefinitio .join(',\n'); } -function generateFieldYupSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string { - let gen = generateFieldTypeYupSchema(config, visitor, field.type); +function generateFieldYupSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, circularTypes: Set): string { + let gen = generateFieldTypeYupSchema(config, visitor, field.type, undefined, circularTypes); if (config.directives && field.directives) { const formatted = formatDirectiveConfig(config.directives); gen += buildApi(formatted, field.directives); } - return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount); + return indent(`${field.name.value}: ${maybeLazy(field.type, gen, config, circularTypes)}`, indentCount); } -function generateFieldTypeYupSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, type: TypeNode, parentType?: TypeNode): string { +function generateFieldTypeYupSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, type: TypeNode, parentType?: TypeNode, circularTypes: Set): string { if (isListType(type)) { - const gen = generateFieldTypeYupSchema(config, visitor, type.type, type); + const gen = generateFieldTypeYupSchema(config, visitor, type.type, type, circularTypes); if (!isNonNullType(parentType)) - return `yup.array(${maybeLazy(type.type, gen)}).defined().nullable()`; + return `yup.array(${maybeLazy(type.type, gen, config, circularTypes)}).defined().nullable()`; - return `yup.array(${maybeLazy(type.type, gen)}).defined()`; + return `yup.array(${maybeLazy(type.type, gen, config, circularTypes)}).defined()`; } if (isNonNullType(type)) { - const gen = generateFieldTypeYupSchema(config, visitor, type.type, type); - return maybeLazy(type.type, gen); + const gen = generateFieldTypeYupSchema(config, visitor, type.type, type, circularTypes); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeYupSchema(config, visitor, type.name); @@ -380,11 +384,25 @@ function generateNameNodeYupSchema(config: ValidationSchemaPluginConfig, visitor } } -function maybeLazy(type: TypeNode, schema: string): string { - if (isNamedType(type) && isInput(type.name.value)) { +function maybeLazy( + type: TypeNode, + schema: string, + config: ValidationSchemaPluginConfig, + circularTypes: Set +): string { + if (isNamedType(type)) { // https://github.com/jquense/yup/issues/1283#issuecomment-786559444 - return `yup.lazy(() => ${schema})`; + const typeName = type.name.value; + + if (config.lazyStrategy === 'all' && isInput(typeName)) { + return `yup.lazy(() => ${schema})`; + } + + if (config.lazyStrategy === 'circular' && circularTypes.has(typeName)) { + return `yup.lazy(() => ${schema})`; + } } + return schema; } diff --git a/src/zod/index.ts b/src/zod/index.ts index fc77e16d..7d541342 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -29,12 +29,15 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { findCircularTypes } from '../utils.js'; const anySchema = `definedNonNullAnySchema`; export class ZodSchemaVisitor extends BaseSchemaVisitor { constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { super(schema, config); + this.circularTypes = findCircularTypes(schema); + this.config.lazyStrategy ??= 'all'; } importValidationSchema(): string { @@ -95,7 +98,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -125,7 +128,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { get ObjectTypeDefinition() { return { - leave: ObjectTypeDefinitionBuilder(this.config.withObjectType, (node: ObjectTypeDefinitionNode) => { + leave: ObjectTypeDefinitionBuilder(this.config.withObjectType && !this.config.inputOnly, (node: ObjectTypeDefinitionNode) => { const visitor = this.createVisitor('output'); const name = visitor.convertName(node.name.value); const typeName = visitor.prefixTypeNamespace(name); @@ -136,7 +139,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -253,7 +256,8 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { name: string, ) { const typeName = visitor.prefixTypeNamespace(name); - const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const discriminator = this.config.inputDiscriminator ? `\t${this.config.inputDiscriminator}: z.literal('${name}'),` : '' + const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -261,7 +265,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(['z.object({', shape, '})'].join('\n')) + .withContent(['z.object({', discriminator, shape, '})'].join('\n')) .string; case 'function': @@ -270,30 +274,30 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withBlock([indent(`return z.object({`), discriminator, shape, indent('})')].join('\n')) .string; } } } -function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number): string { - const gen = generateFieldTypeZodSchema(config, visitor, field, field.type); - return indent(`${field.name.value}: ${maybeLazy(field.type, gen)}`, indentCount); +function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, circularTypes: Set): string { + const gen = generateFieldTypeZodSchema(config, visitor, field, field.type, undefined, circularTypes); + return indent(`${field.name.value}: ${maybeLazy(field.type, gen, config, circularTypes)}`, indentCount); } -function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string { +function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode, circularTypes: Set): string { if (isListType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type); + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, circularTypes); if (!isNonNullType(parentType)) { - const arrayGen = `z.array(${maybeLazy(type.type, gen)})`; + const arrayGen = `z.array(${maybeLazy(type.type, gen, config, circularTypes)})`; const maybeLazyGen = applyDirectives(config, field, arrayGen); return `${maybeLazyGen}.nullish()`; } - return `z.array(${maybeLazy(type.type, gen)})`; + return `z.array(${maybeLazy(type.type, gen, config, circularTypes)})`; } if (isNonNullType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type); - return maybeLazy(type.type, gen); + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, circularTypes); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeZodSchema(config, visitor, type.name); @@ -374,9 +378,24 @@ function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor } } -function maybeLazy(type: TypeNode, schema: string): string { - if (isNamedType(type) && isInput(type.name.value)) - return `z.lazy(() => ${schema})`; +function maybeLazy( + type: TypeNode, + schema: string, + config: ValidationSchemaPluginConfig, + circularTypes: Set +): string { + if (isNamedType(type)) { + // https://github.com/jquense/yup/issues/1283#issuecomment-786559444 + const typeName = type.name.value; + + if (config.lazyStrategy === 'all' && isInput(typeName)) { + return `z.lazy(() => ${schema})`; + } + + if (config.lazyStrategy === 'circular' && circularTypes.has(typeName)) { + return `z.lazy(() => ${schema})`; + } + } return schema; } diff --git a/src/zodv4/index.ts b/src/zodv4/index.ts new file mode 100644 index 00000000..963ae0c8 --- /dev/null +++ b/src/zodv4/index.ts @@ -0,0 +1,406 @@ + +import type { + EnumTypeDefinitionNode, + FieldDefinitionNode, + GraphQLSchema, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + InterfaceTypeDefinitionNode, + NameNode, + ObjectTypeDefinitionNode, + TypeNode, + UnionTypeDefinitionNode, +} from 'graphql'; + +import type { ValidationSchemaPluginConfig } from '../config.js'; +import type { Visitor } from '../visitor.js'; +import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers'; +import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; +import { + Kind, +} from 'graphql'; +import { buildApi, formatDirectiveConfig } from '../directive.js'; +import { + escapeGraphQLCharacters, + InterfaceTypeDefinitionBuilder, + isInput, + isListType, + isNamedType, + isNonNullType, + ObjectTypeDefinitionBuilder, +} from '../graphql.js'; +import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { findCircularTypes } from '../utils.js' + +export class Zodv4SchemaVisitor extends BaseSchemaVisitor { + private circularTypes: Set + constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { + super(schema, config); + this.circularTypes = findCircularTypes(schema); + this.config.lazyStrategy ??= 'all'; + } + + importValidationSchema(): string { + return `import { z } from 'zod/v4'`; + } + + initialEmit(): string { + return ( + `\n${ + [ + new DeclarationBlock({}) + .asKind('type') + .withName('Properties') + .withContent(['Required<{', ' [K in keyof T]: z.ZodType;', '}>'].join('\n')) + .string, + ...this.enumDeclarations, + ].join('\n')}` + ); + } + + get InputObjectTypeDefinition() { + return { + leave: (node: InputObjectTypeDefinitionNode) => { + const visitor = this.createVisitor('input'); + const name = visitor.convertName(node.name.value); + this.importTypes.push(name); + return this.buildInputFields(node.fields ?? [], visitor, name); + }, + }; + } + + get InterfaceTypeDefinition() { + return { + leave: InterfaceTypeDefinitionBuilder(this.config.withObjectType, (node: InterfaceTypeDefinitionNode) => { + const visitor = this.createVisitor('output'); + const name = visitor.convertName(node.name.value); + const typeName = visitor.prefixTypeNamespace(name); + this.importTypes.push(name); + + // Building schema for field arguments. + const argumentBlocks = this.buildTypeDefinitionArguments(node, visitor); + const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; + + // Building schema for fields. + const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); + + switch (this.config.validationSchemaExportType) { + case 'const': + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent([`z.object({`, shape, '})'].join('\n')) + .string + appendArguments + ); + + case 'function': + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .string + appendArguments + ); + } + }), + }; + } + + get ObjectTypeDefinition() { + return { + leave: ObjectTypeDefinitionBuilder(this.config.withObjectType && !this.config.inputOnly, (node: ObjectTypeDefinitionNode) => { + const visitor = this.createVisitor('output'); + const name = visitor.convertName(node.name.value); + const typeName = visitor.prefixTypeNamespace(name); + this.importTypes.push(name); + + // Building schema for field arguments. + const argumentBlocks = this.buildTypeDefinitionArguments(node, visitor); + const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; + + // Building schema for fields. + const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); + + switch (this.config.validationSchemaExportType) { + case 'const': + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent( + [ + `z.object({`, + indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), + shape, + '})', + ].join('\n'), + ) + .string + appendArguments + ); + + case 'function': + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock( + [ + indent(`return z.object({`), + indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), + shape, + indent('})'), + ].join('\n'), + ) + .string + appendArguments + ); + } + }), + }; + } + + get EnumTypeDefinition() { + return { + leave: (node: EnumTypeDefinitionNode) => { + const visitor = this.createVisitor('both'); + const enumname = visitor.convertName(node.name.value); + const enumTypeName = visitor.prefixTypeNamespace(enumname); + this.importTypes.push(enumname); + + // hoist enum declarations + this.enumDeclarations.push( + this.config.enumsAsTypes + ? new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`z.enum([${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')}])`) + .string + : new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`z.enum(${enumTypeName})`) + .string, + ); + }, + }; + } + + get UnionTypeDefinition() { + return { + leave: (node: UnionTypeDefinitionNode) => { + if (!node.types || !this.config.withObjectType) + return; + const visitor = this.createVisitor('output'); + const unionName = visitor.convertName(node.name.value); + const unionElements = node.types.map((t) => { + const element = visitor.convertName(t.name.value); + const typ = visitor.getType(t.name.value); + if (typ?.astNode?.kind === 'EnumTypeDefinition') + return `${element}Schema`; + + switch (this.config.validationSchemaExportType) { + case 'const': + return `${element}Schema`; + case 'function': + default: + return `${element}Schema()`; + } + }).join(', '); + const unionElementsCount = node.types.length ?? 0; + + const union = unionElementsCount > 1 ? `z.union([${unionElements}])` : unionElements; + + switch (this.config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}).export().asKind('const').withName(`${unionName}Schema`).withContent(union).string; + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${unionName}Schema()`) + .withBlock(indent(`return ${union}`)) + .string; + } + }, + }; + } + + protected buildInputFields( + fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[], + visitor: Visitor, + name: string, + ) { + const typeName = visitor.prefixTypeNamespace(name); + const discriminator = this.config.inputDiscriminator ? `\t${this.config.inputDiscriminator}: z.literal('${name}'),` : '' + const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); + + switch (this.config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent(['z.object({', discriminator, shape, '})'].join('\n')) + .string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock([indent(`return z.object({`), discriminator, shape, indent('})')].join('\n')) + .string; + } + } +} + +function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, circularTypes: Set): string { + const gen = generateFieldTypeZodSchema(config, visitor, field, field.type, undefined, circularTypes); + return indent(`${field.name.value}: ${maybeLazy(field.type, gen, config, circularTypes)}`, indentCount); +} + +function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNoder, circularTypes: Set): string { + if (isListType(type)) { + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, circularTypes); + if (!isNonNullType(parentType)) { + const arrayGen = `z.array(${maybeLazy(type.type, gen, config, circularTypes)})`; + const maybeLazyGen = applyDirectives(config, field, arrayGen); + return `${maybeLazyGen}.nullish()`; + } + return `z.array(${maybeLazy(type.type, gen, config, circularTypes)})`; + } + if (isNonNullType(type)) { + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, circularTypes); + return maybeLazy(type.type, gen, config, circularTypes); + } + if (isNamedType(type)) { + const gen = generateNameNodeZodSchema(config, visitor, type.name); + if (isListType(parentType)) + return `${gen}.nullable()`; + + let appliedDirectivesGen = applyDirectives(config, field, gen); + + if (field.kind === Kind.INPUT_VALUE_DEFINITION) { + const { defaultValue } = field; + + if (defaultValue?.kind === Kind.INT || defaultValue?.kind === Kind.FLOAT || defaultValue?.kind === Kind.BOOLEAN) + appliedDirectivesGen = `${appliedDirectivesGen}.default(${defaultValue.value})`; + + if (defaultValue?.kind === Kind.STRING || defaultValue?.kind === Kind.ENUM) { + if (config.useEnumTypeAsDefaultValue && defaultValue?.kind !== Kind.STRING) { + let value = convertNameParts(defaultValue.value, resolveExternalModuleAndFn('change-case-all#pascalCase'), config.namingConvention?.transformUnderscore); + + if (config.namingConvention?.enumValues) + value = convertNameParts(defaultValue.value, resolveExternalModuleAndFn(config.namingConvention?.enumValues), config.namingConvention?.transformUnderscore); + + appliedDirectivesGen = `${appliedDirectivesGen}.default(${type.name.value}.${value})`; + } + else { + appliedDirectivesGen = `${appliedDirectivesGen}.default("${escapeGraphQLCharacters(defaultValue.value)}")`; + } + } + } + + if (isNonNullType(parentType)) { + if (visitor.shouldEmitAsNotAllowEmptyString(type.name.value)) + return `${appliedDirectivesGen}.min(1)`; + + return appliedDirectivesGen; + } + if (isListType(parentType)) + return `${appliedDirectivesGen}.nullable()`; + + return `${appliedDirectivesGen}.nullish()`; + } + console.warn('unhandled type:', type); + return ''; +} + +function applyDirectives(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode, gen: string): string { + if (config.directives && field.directives) { + const formatted = formatDirectiveConfig(config.directives); + return gen + buildApi(formatted, field.directives); + } + return gen; +} + +function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode): string { + const converter = visitor.getNameNodeConverter(node); + + switch (converter?.targetKind) { + case 'InterfaceTypeDefinition': + case 'InputObjectTypeDefinition': + case 'ObjectTypeDefinition': + case 'UnionTypeDefinition': + // using switch-case rather than if-else to allow for future expansion + switch (config.validationSchemaExportType) { + case 'const': + return `${converter.convertName()}Schema`; + case 'function': + default: + return `${converter.convertName()}Schema()`; + } + case 'EnumTypeDefinition': + return `${converter.convertName()}Schema`; + case 'ScalarTypeDefinition': + return zod4Scalar(config, visitor, node.value); + default: + if (converter?.targetKind) + console.warn('Unknown targetKind', converter?.targetKind); + + return zod4Scalar(config, visitor, node.value); + } +} + +function maybeLazy( + type: TypeNode, + schema: string, + config: ValidationSchemaPluginConfig, + circularTypes: Set +): string { + if (isNamedType(type)) { + const typeName = type.name.value; + + if (config.lazyStrategy === 'all' && isInput(typeName)) { + return `z.lazy(() => ${schema})`; + } + + if (config.lazyStrategy === 'circular' && circularTypes.has(typeName)) { + return `z.lazy(() => ${schema})`; + } + } + + return schema; +} + +function zod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scalarName: string): string { + if (config.scalarSchemas?.[scalarName]) + return config.scalarSchemas[scalarName]; + + const tsType = visitor.getScalarType(scalarName); + switch (tsType) { + case 'string': + return `z.string()`; + case 'number': + return `z.number()`; + case 'boolean': + return `z.boolean()`; + } + + if (config.defaultScalarTypeSchema) { + return config.defaultScalarTypeSchema; + } + + console.warn('unhandled scalar name:', scalarName); + return 'z.any()'; +}