diff --git a/README.md b/README.md index 6225d43a..0be79ec4 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ type: `ValidationSchema` default: `'yup'` Specify generete validation schema you want. -You can specify `yup` or `zod` or `myzod`. +You can specify `yup` or `zod` or `zodv4` or `myzod`. ```yml generates: @@ -87,6 +87,77 @@ import { GeneratedInput } from './graphql' /* generates validation schema here */ ``` +### `inputDiscriminator` + +type: `string` + +When provided, adds a discriminator to the input schemas with a literal value of the input type name. + +```yml +generates: + path/to/graphql.ts: + plugins: + - typescript + path/to/validation.ts: + plugins: + - typescript-validation-schema + config: + inputDiscriminator: __kind # discriminator key +``` + +### `lazyStrategy` + +type: `LazyStrategy` default: `'all'` + +Specify if lazy() => should be added all references or only circular references. + +You can specify `all` or `circular`. + +```yml +generates: + path/to/graphql.ts: + plugins: + - typescript + path/to/validation.ts: + plugins: + - typescript-validation-schema + config: + lazyStrategy: circular +``` + +### `separateSchemaObject` + +type: `boolean` default `false` + +Will separate the schema object from the object definition when set to true. + +```yml +generates: + path/to/graphql.ts: + plugins: + - typescript + path/to/validation.ts: + plugins: + - typescript-validation-schema + config: + schemaObjectSeparate: true +``` + +Then the generator generates code like below. + +```ts +/* When set to false */ +// If validationSchemaExportType is 'const' and Zod as an example +export const Schema: z.ZodObject = z.object({foo: bar}) + + +/* When set to false */ +// If validationSchemaExportType is 'const' and Zod as an example +// While these seem the same, Zod for example will add [x: string]: unknown to the first example while this will prevent that. +export const schemaObject: SchemaType = {foo: bar} +export const Schema = z.object(schemaObject) +``` + ### `schemaNamespacedImportName` type: `string` @@ -215,6 +286,16 @@ config: Email: z.string().email() ``` +#### zodv4 schema + +```yml +config: + schema: zodv4 + scalarSchemas: + Date: z.date() + Email: z.email() +``` + ### `defaultScalarTypeSchema` type: `string` diff --git a/src/config.ts b/src/config.ts index d6ec9f2b..093ff4c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,9 @@ 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' | 'zodv4' | 'myzod' | 'valibot'; export type ValidationSchemaExportType = 'function' | 'const'; +export type LazyStrategy = 'all' | 'circular' export interface DirectiveConfig { [directive: string]: { @@ -54,6 +55,59 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ importFrom?: string + /** + * @description When provided, adds a discriminator to the input schemas with a literal value of the input type name. + * @default "" + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/types.ts: + * plugins: + * - typescript + * path/to/schemas.ts: + * plugins: + * - graphql-codegen-validation-schema + * config: + * schema: yup + * inputDiscriminator: __kind + * ``` + */ + inputDiscriminator?: string + /** + * @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 Will separate the schema object from the object definition when set to true. + * @default false + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/file.ts: + * plugins: + * - typescript + * - graphql-codegen-validation-schema + * config: + * schema: yup + * schemaObjectSeparate: true + * ``` + */ + separateSchemaObject?: boolean; /** * @description If defined, will use named imports from the specified module (defined in `importFrom`) * rather than individual imports for each type. diff --git a/src/index.ts b/src/index.ts index 39dd1e8a..aa9f7ae7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ 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 +33,8 @@ export const plugin: PluginFunction constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { super(schema, config); + this.circularTypes = findCircularTypes(schema) + this.config.lazyStrategy ??= 'all' } importValidationSchema(): string { @@ -43,11 +47,10 @@ export class MyZodSchemaVisitor extends BaseSchemaVisitor { initialEmit(): string { return ( - `\n${ - [ - new DeclarationBlock({}).export().asKind('const').withName(`${anySchema}`).withContent(`myzod.object({})`).string, - ...this.enumDeclarations, - ].join('\n')}` + `\n${[ + new DeclarationBlock({}).export().asKind('const').withName(`${anySchema}`).withContent(`myzod.object({})`).string, + ...this.enumDeclarations, + ].join('\n')}` ); } @@ -75,29 +78,59 @@ 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'); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, '', typeName, shape) switch (this.config.validationSchemaExportType) { case 'const': - return ( - new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${name}Schema: myzod.Type<${typeName}>`) - .withContent([`myzod.object({`, shape, '})'].join('\n')) - .string + appendArguments - ); + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['myzod.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: myzod.Type<${typeName}>`) + .withContent([`myzod.object({`, shape, '})'].join('\n')) + .string + appendArguments + ); + } case 'function': default: - return ( - new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): myzod.Type<${typeName}>`) - .withBlock([indent(`return myzod.object({`), shape, indent('})')].join('\n')) - .string + appendArguments - ); + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return myzod.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): myzod.Type<${typeName}>`) + .withBlock([indent('return myzod.object({'), shape, indent('})')].join('\n')) + .string + appendArguments + ); + } } }), }; @@ -116,43 +149,84 @@ 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'); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, `__typename: myzod.literal('${node.name.value}').optional(),`, typeName, shape) switch (this.config.validationSchemaExportType) { case 'const': - return ( - new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${name}Schema: myzod.Type<${typeName}>`) - .withContent( - [ - `myzod.object({`, - indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), - shape, - '})', - ].join('\n'), + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent( + [ + 'myzod.object(', + indent(schemaObject.name, 1), + ')', + ].join('\n'), + ) + .string + appendArguments ) - .string + appendArguments - ); - + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: myzod.Type<${typeName}>`) + .withContent( + [ + 'myzod.object({', + indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), + shape, + '})', + ].join('\n'), + ) + .string + appendArguments + ); + } case 'function': default: - return ( - new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): myzod.Type<${typeName}>`) - .withBlock( - [ - indent(`return myzod.object({`), - indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), - shape, - indent('})'), - ].join('\n'), + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock( + [ + indent('return myzod.object('), + indent(schemaObject.name, 1), + indent(')'), + ].join('\n'), + ) + .string + appendArguments ) - .string + appendArguments - ); + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): myzod.Type<${typeName}>`) + .withBlock( + [ + indent('return myzod.object({'), + indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), + shape, + indent('})'), + ].join('\n'), + ) + .string + appendArguments + ); + } } }), }; @@ -237,47 +311,73 @@ 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 shape = fields.map(field => generateFieldMyZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); + const discriminatorField = + this.config.inputDiscriminator ? + `${indent(this.config.inputDiscriminator, this.config.validationSchemaExportType === 'const' ? 1 : 2)}: myzod.literal('${name}'),'),` + : '' + const schemaObject = buildSchemaObject(name, discriminatorField, typeName, shape) switch (this.config.validationSchemaExportType) { case 'const': - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${name}Schema: myzod.Type<${typeName}>`) - .withContent(['myzod.object({', shape, '})'].join('\n')) - .string; - + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['myzod.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string; + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: myzod.Type<${typeName}>`) + .withContent(['myzod.object({', shape, '})'].join('\n')) + .string; + } case 'function': default: - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): myzod.Type<${typeName}>`) - .withBlock([indent(`return myzod.object({`), shape, indent('})')].join('\n')) - .string; + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return myzod.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string; + + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): myzod.Type<${typeName}>`) + .withBlock([indent(`return myzod.object({`), 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, 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, circularTypes: Set, parentType?: TypeNode): string { if (isListType(type)) { - const gen = generateFieldTypeMyZodSchema(config, visitor, field, type.type, type); + const gen = generateFieldTypeMyZodSchema(config, visitor, field, type.type, circularTypes, type); 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, circularTypes, type); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeMyZodSchema(config, visitor, type.name); @@ -358,11 +458,20 @@ 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) { + 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; + return schema } function myzod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scalarName: string): string { @@ -386,3 +495,11 @@ function myzod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, sc console.warn('unhandled name:', scalarName); return anySchema; } + +function buildSchemaObject(name: string, discriminator: string, typeName: string, shape: string | undefined) { + const objectName = name.charAt(0).toLowerCase() + name.slice(1) + 'SchemaObject' + return { + string: `export const ${objectName}: Properties<${typeName}> = {\n${discriminator}\n${shape}\n}\n\n`, + name: objectName, + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..43176af4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,62 @@ +import { + GraphQLSchema, + GraphQLNamedType, + getNamedType, + isObjectType, + isInputObjectType, + isInterfaceType, + isUnionType, + GraphQLType, +} from 'graphql'; + +export function findCircularTypes(schema: GraphQLSchema): Set { + const circular = new Set(); + const visited = new Set(); + const stack = new Set(); + + function visit(typeName: string): void { + if (stack.has(typeName)) { + circular.add(typeName); + return; + } + + if (visited.has(typeName)) { + return; + } + + visited.add(typeName); + stack.add(typeName); + + const type: GraphQLNamedType | undefined = schema.getType(typeName); + if (!type) { + stack.delete(typeName); + return; + } + + if (isUnionType(type)) { + for (const subtype of type.getTypes()) { + visit(subtype.name); + } + } else if ( + isObjectType(type) || + isInputObjectType(type) || + isInterfaceType(type) + ) { + const fields = type.getFields(); + for (const field of Object.values(fields)) { + const namedType = getNamedType(field.type as GraphQLType); + visit(namedType.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..51081009 100644 --- a/src/valibot/index.ts +++ b/src/valibot/index.ts @@ -24,10 +24,14 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { findCircularTypes } from 'src/utils.js'; export class ValibotSchemaVisitor extends BaseSchemaVisitor { + private circularTypes: Set constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { super(schema, config); + this.circularTypes = findCircularTypes(schema) + this.config.lazyStrategy ??= 'all' } importValidationSchema(): string { @@ -66,18 +70,59 @@ 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'); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, '', typeName, shape) switch (this.config.validationSchemaExportType) { + case 'const': + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['v.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: v.GenericSchema<${typeName}>`) + .withContent([`v.object({`, shape, '})'].join('\n')) + .string + appendArguments + ); + } + + case 'function': default: - return ( - new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): v.GenericSchema<${typeName}>`) - .withBlock([indent(`return v.object({`), shape, indent('})')].join('\n')) - .string + appendArguments - ); + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return v.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): v.GenericSchema<${typeName}>`) + .withBlock([indent('return v.object({'), shape, indent('})')].join('\n')) + .string + appendArguments + ); + } } }), }; @@ -96,25 +141,84 @@ 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'); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, `__typename: v.optional(v.literal('${node.name.value}')),`, typeName, shape) switch (this.config.validationSchemaExportType) { + case 'const': + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent( + [ + 'v.object(', + indent(schemaObject.name, 1), + ')', + ].join('\n'), + ) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: v.GenericSchema<${typeName}>`) + .withContent( + [ + 'v.object({', + indent(`__typename: v.optional(v.literal('${node.name.value}')),`, 2), + shape, + '})', + ].join('\n'), + ) + .string + appendArguments + ); + } + case 'function': default: - return ( - new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): v.GenericSchema<${typeName}>`) - .withBlock( - [ - indent(`return v.object({`), - indent(`__typename: v.optional(v.literal('${node.name.value}')),`, 2), - shape, - indent('})'), - ].join('\n'), + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock( + [ + indent('return v.object('), + indent(schemaObject.name, 1), + indent(')'), + ].join('\n'), + ) + .string + appendArguments ) - .string + appendArguments - ); + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): v.GenericSchema<${typeName}>`) + .withBlock( + [ + indent('return v.object({'), + indent(`__typename: v.optional(v.literal('${node.name.value}')),`, 2), + shape, + indent('})'), + ].join('\n'), + ) + .string + appendArguments + ); + } } }), }; @@ -189,37 +293,72 @@ 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 shape = fields.map(field => generateFieldValibotSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); + const discriminatorField = + this.config.inputDiscriminator ? + `${indent(this.config.inputDiscriminator, this.config.validationSchemaExportType === 'const' ? 1 : 2)}: v.literal('${name}'),'),` + : '' + const schemaObject = buildSchemaObject(name, discriminatorField, typeName, shape) switch (this.config.validationSchemaExportType) { + case 'const': + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['v.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string; + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: v.GenericSchema<${typeName}>`) + .withContent(['v.object({', shape, '})'].join('\n')) + .string; + } + case 'function': default: - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): v.GenericSchema<${typeName}>`) - .withBlock([indent(`return v.object({`), shape, indent('})')].join('\n')) - .string; + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return v.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string; + + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): v.GenericSchema<${typeName}>`) + .withBlock([indent(`return v.object({`), 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, 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, circularTypes: Set, parentType?: TypeNode): 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, circularTypes, type); + 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, circularTypes, type); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeValibotSchema(config, visitor, type.name); @@ -283,11 +422,20 @@ 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) { + if (isNamedType(type)) { + 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; + return schema } function valibot4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scalarName: string): string { @@ -311,3 +459,12 @@ function valibot4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, console.warn('unhandled scalar name:', scalarName); return 'v.any()'; } + + +function buildSchemaObject(name: string, discriminator: string, typeName: string, shape: string | undefined) { + const objectName = name.charAt(0).toLowerCase() + name.slice(1) + 'SchemaObject' + return { + string: `export const ${objectName}: ${typeName} = {\n${discriminator}\n${shape}\n}\n\n`, + name: objectName, + } +} diff --git a/src/yup/index.ts b/src/yup/index.ts index 7370e9ea..c9781e48 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -29,10 +29,14 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { findCircularTypes } from 'src/utils.js'; export class YupSchemaVisitor extends BaseSchemaVisitor { + private circularTypes: Set constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { super(schema, config); + this.circularTypes = findCircularTypes(schema) + this.config.lazyStrategy ??= 'all' } importValidationSchema(): string { @@ -43,20 +47,18 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { if (!this.config.withObjectType) return `\n${this.enumDeclarations.join('\n')}`; return ( - `\n${ - this.enumDeclarations.join('\n') - }\n${ - new DeclarationBlock({}) - .asKind('function') - .withName('union(...schemas: ReadonlyArray>): yup.MixedSchema') - .withBlock( - [ - indent('return yup.mixed().test({'), - indent('test: (value) => schemas.some((schema) => schema.isValidSync(value))', 2), - indent('}).defined()'), - ].join('\n'), - ) - .string}` + `\n${this.enumDeclarations.join('\n') + }\n${new DeclarationBlock({}) + .asKind('function') + .withName('union(...schemas: ReadonlyArray>): yup.MixedSchema') + .withBlock( + [ + indent('return yup.mixed().test({'), + indent('test: (value) => schemas.some((schema) => schema.isValidSync(value))', 2), + indent('}).defined()'), + ].join('\n'), + ) + .string}` ); } @@ -85,31 +87,61 @@ 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'); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, '', typeName, shape) switch (this.config.validationSchemaExportType) { case 'const': - return ( - new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${name}Schema: yup.ObjectSchema<${typeName}>`) - .withContent([`yup.object({`, shape, '})'].join('\n')) - .string + appendArguments - ); + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['yup.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: yup.ObjectSchema<${typeName}>`) + .withContent([`yup.object({`, shape, '})'].join('\n')) + .string + appendArguments + ); + } case 'function': default: - return ( - new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): yup.ObjectSchema<${typeName}>`) - .withBlock([indent(`return yup.object({`), shape, indent('})')].join('\n')) - .string + appendArguments - ); + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return yup.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): yup.ObjectSchema<${typeName}>`) + .withBlock([indent('return yup.object({'), shape, indent('})')].join('\n')) + .string + appendArguments + ); + } } }), }; @@ -130,41 +162,83 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { // Building schema for fields. const shape = shapeFields(node.fields, this.config, visitor); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, `__typename: yup.string<'${node.name.value}'>().optional(),`, typeName, shape) switch (this.config.validationSchemaExportType) { + case 'const': - return ( - new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${name}Schema: yup.ObjectSchema<${typeName}>`) - .withContent( - [ - `yup.object({`, - indent(`__typename: yup.string<'${node.name.value}'>().optional(),`, 2), - shape, - '})', - ].join('\n'), + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent( + [ + 'z.object(', + indent(schemaObject.name, 1), + ')', + ].join('\n'), + ) + .string + appendArguments ) - .string + appendArguments - ); - + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: yup.ObjectSchema<${typeName}>`) + .withContent( + [ + 'z.object({', + indent(`__typename: yup.string<'${node.name.value}'>().optional(),`, 2), + shape, + '})', + ].join('\n'), + ) + .string + appendArguments + ); + } case 'function': default: - return ( - new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): yup.ObjectSchema<${typeName}>`) - .withBlock( - [ - indent(`return yup.object({`), - indent(`__typename: yup.string<'${node.name.value}'>().optional(),`, 2), - shape, - indent('})'), - ].join('\n'), + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock( + [ + indent('return z.object('), + indent(schemaObject.name, 1), + indent(')'), + ].join('\n'), + ) + .string + appendArguments ) - .string + appendArguments - ); + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): yup.ObjectSchema<${typeName}>`) + .withBlock( + [ + indent('return z.object({'), + indent(`__typename: yup.string<'${node.name.value}'>().optional(),`, 2), + shape, + indent('})'), + ].join('\n'), + ) + .string + appendArguments + ); + } } }), }; @@ -257,24 +331,50 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { ) { const typeName = visitor.prefixTypeNamespace(name); const shape = shapeFields(fields, this.config, visitor); - + const discriminatorField = + this.config.inputDiscriminator ? + `${indent(this.config.inputDiscriminator, this.config.validationSchemaExportType === 'const' ? 1 : 2)}: yup.string<'${name}'>(),'),` + : '' + const schemaObject = buildSchemaObject(name, discriminatorField, typeName, shape) switch (this.config.validationSchemaExportType) { case 'const': - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${name}Schema: yup.ObjectSchema<${typeName}>`) - .withContent(['yup.object({', shape, '})'].join('\n')) - .string; - + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['yup.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string; + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: yup.ObjectSchema<${typeName}>`) + .withContent(['yup.object({', shape, '})'].join('\n')) + .string; + } case 'function': default: - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): yup.ObjectSchema<${typeName}>`) - .withBlock([indent(`return yup.object({`), shape, indent('})')].join('\n')) - .string; + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return yup.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string; + + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): yup.ObjectSchema<${typeName}>`) + .withBlock([indent(`return yup.object({`), shape, indent('})')].join('\n')) + .string; + } } } } @@ -282,7 +382,7 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { function shapeFields(fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[] | undefined, config: ValidationSchemaPluginConfig, visitor: Visitor) { return fields ?.map((field) => { - let fieldSchema = generateFieldYupSchema(config, visitor, field, 2); + let fieldSchema = generateFieldYupSchema(config, visitor, field, 2, this.circularTypes); if (field.kind === Kind.INPUT_VALUE_DEFINITION) { const { defaultValue } = field; @@ -318,26 +418,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, 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, circularTypes: Set, parentType?: TypeNode): string { if (isListType(type)) { - const gen = generateFieldTypeYupSchema(config, visitor, type.type, type); + const gen = generateFieldTypeYupSchema(config, visitor, type.type, circularTypes, type); 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, circularTypes, type); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeYupSchema(config, visitor, type.name); @@ -380,12 +480,20 @@ function generateNameNodeYupSchema(config: ValidationSchemaPluginConfig, visitor } } -function maybeLazy(type: TypeNode, schema: string): string { - if (isNamedType(type) && isInput(type.name.value)) { - // https://github.com/jquense/yup/issues/1283#issuecomment-786559444 - return `yup.lazy(() => ${schema})`; +function maybeLazy(type: TypeNode, schema: string, config: ValidationSchemaPluginConfig, circularTypes: Set) { + if (isNamedType(type)) { + 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; + + return schema } function yup4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scalarName: string): string { @@ -409,3 +517,12 @@ function yup4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scal console.warn('unhandled name:', scalarName); return `yup.mixed()`; } + + +function buildSchemaObject(name: string, discriminator: string, typeName: string, shape: string | undefined) { + const objectName = name.charAt(0).toLowerCase() + name.slice(1) + 'SchemaObject' + return { + string: `export const ${objectName}: ${typeName} = {\n${discriminator}\n${shape}\n}\n\n`, + name: objectName, + } +} diff --git a/src/zod/index.ts b/src/zod/index.ts index fc77e16d..7e8109e0 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -29,12 +29,16 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { findCircularTypes } from 'src/utils.js'; const anySchema = `definedNonNullAnySchema`; export class ZodSchemaVisitor extends BaseSchemaVisitor { + private circularTypes: Set constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { super(schema, config); + this.circularTypes = findCircularTypes(schema) + this.config.lazyStrategy ??= 'all' } importValidationSchema(): string { @@ -43,31 +47,30 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { initialEmit(): string { return ( - `\n${ - [ - new DeclarationBlock({}) - .asKind('type') - .withName('Properties') - .withContent(['Required<{', ' [K in keyof T]: z.ZodType;', '}>'].join('\n')) - .string, - // Unfortunately, zod doesn’t provide non-null defined any schema. - // This is a temporary hack until it is fixed. - // see: https://github.com/colinhacks/zod/issues/884 - new DeclarationBlock({}).asKind('type').withName('definedNonNullAny').withContent('{}').string, - new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`isDefinedNonNullAny`) - .withContent(`(v: any): v is definedNonNullAny => v !== undefined && v !== null`) - .string, - new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${anySchema}`) - .withContent(`z.any().refine((v) => isDefinedNonNullAny(v))`) - .string, - ...this.enumDeclarations, - ].join('\n')}` + `\n${[ + new DeclarationBlock({}) + .asKind('type') + .withName('Properties') + .withContent(['Required<{', ' [K in keyof T]: z.ZodType;', '}>'].join('\n')) + .string, + // Unfortunately, zod doesn’t provide non-null defined any schema. + // This is a temporary hack until it is fixed. + // see: https://github.com/colinhacks/zod/issues/884 + new DeclarationBlock({}).asKind('type').withName('definedNonNullAny').withContent('{}').string, + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`isDefinedNonNullAny`) + .withContent(`(v: any): v is definedNonNullAny => v !== undefined && v !== null`) + .string, + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${anySchema}`) + .withContent(`z.any().refine((v) => isDefinedNonNullAny(v))`) + .string, + ...this.enumDeclarations, + ].join('\n')}` ); } @@ -95,29 +98,59 @@ 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'); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, '', typeName, shape) 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 - ); + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['z.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string + appendArguments + ) + case false: + default: + 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 - ); + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return z.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock([indent('return z.object({'), shape, indent('})')].join('\n')) + .string + appendArguments + ); + } } }), }; @@ -136,43 +169,84 @@ 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'); + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, `__typename: z.literal('${name}').optional(),`, typeName, shape) 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'), + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent( + [ + 'z.object(', + indent(schemaObject.name, 1), + ')', + ].join('\n'), + ) + .string + appendArguments ) - .string + appendArguments - ); - + case false: + default: + 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'), + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock( + [ + indent('return z.object('), + indent(schemaObject.name, 1), + indent(')'), + ].join('\n'), + ) + .string + appendArguments ) - .string + appendArguments - ); + case false: + 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 + ); + } } }), }; @@ -253,47 +327,74 @@ 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 shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2, this.circularTypes)).join(',\n'); + const discriminatorField = + this.config.inputDiscriminator ? + `${indent(this.config.inputDiscriminator, this.config.validationSchemaExportType === 'const' ? 1 : 2)}: z.literal('${name}'),` + : '' + const schemaObject = buildSchemaObject(name, discriminatorField, typeName, shape) switch (this.config.validationSchemaExportType) { case 'const': - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${name}Schema: z.ZodObject>`) - .withContent(['z.object({', shape, '})'].join('\n')) - .string; - + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['z.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string; + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent(['z.object({', shape, '})'].join('\n')) + .string; + } case 'function': default: - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) - .string; + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return z.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string; + + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock([indent(`return z.object({`), 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, 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, circularTypes: Set, parentType?: TypeNode): string { if (isListType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type); + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, circularTypes, type); 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, circularTypes, type); + return maybeLazy(type.type, gen, config, circularTypes); } if (isNamedType(type)) { const gen = generateNameNodeZodSchema(config, visitor, type.name); @@ -374,11 +475,20 @@ 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) { + if (isNamedType(type)) { + const typeName = type.name.value + + if (config.lazyStrategy === 'all' && isInput(typeName)) { + return `z.lazy(() => ${schema})` + } - return schema; + if (config.lazyStrategy === 'circular' && circularTypes.has(typeName)) { + return `z.lazy(() => ${schema})` + } + } + + return schema } function zod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scalarName: string): string { @@ -402,3 +512,11 @@ function zod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scal console.warn('unhandled scalar name:', scalarName); return anySchema; } + +function buildSchemaObject(name: string, discriminator: string, typeName: string, shape: string | undefined) { + const objectName = name.charAt(0).toLowerCase() + name.slice(1) + 'SchemaObject' + return { + string: `export const ${objectName}: Properties<${typeName}> = {\n${discriminator}\n${shape}\n}\n\n`, + name: objectName, + } +} diff --git a/src/zodv4/index.ts b/src/zodv4/index.ts new file mode 100644 index 00000000..dba57503 --- /dev/null +++ b/src/zodv4/index.ts @@ -0,0 +1,505 @@ +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 'src/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 'zodv4'`; + } + + 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, this.config.validationSchemaExportType === 'const' ? 1 : 2, this.circularTypes)).join(',\n'); + + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, '', typeName, shape) + switch (this.config.validationSchemaExportType) { + case 'const': + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['z.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string + appendArguments + ) + case false: + default: + return ( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent([`z.object({`, shape, '})'].join('\n')) + .string + appendArguments + ); + } + + case 'function': + default: + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return z.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string + appendArguments + ) + case false: + 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, (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, this.config.validationSchemaExportType === 'const' ? 1 : 2, this.circularTypes)).join(',\n'); + + // Building schema object for separateSchemaObject config option + const schemaObject = buildSchemaObject(name, `__typename: z.literal('${name}').optional(),`, typeName, shape) + switch (this.config.validationSchemaExportType) { + case 'const': + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent( + [ + 'z.object(', + indent(schemaObject.name, 1), + ')', + ].join('\n'), + ) + .string + appendArguments + ) + case false: + default: + 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: + switch (this.config.separateSchemaObject) { + case true: + return ( + schemaObject.string + + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock( + [ + indent('return z.object('), + indent(schemaObject.name, 1), + indent(')'), + ].join('\n'), + ) + .string + appendArguments + ) + case false: + 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 shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, this.config.validationSchemaExportType === 'const' ? 1 : 2, this.circularTypes)).join(',\n'); + + const discriminatorField = + this.config.inputDiscriminator ? + `${indent(this.config.inputDiscriminator, this.config.validationSchemaExportType === 'const' ? 1 : 2)}: z.literal('${name}'),` + : '' + const schemaObject = buildSchemaObject(name, discriminatorField, typeName, shape) + switch (this.config.validationSchemaExportType) { + case 'const': + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema`) + .withContent(['z.object(', indent(schemaObject.name, 1), ')'].join('\n')) + .string; + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent(['z.object({', shape, '})'].join('\n')) + .string; + } + case 'function': + default: + switch (this.config.separateSchemaObject) { + case true: + return schemaObject.string + new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema()`) + .withBlock([indent('return z.object('), indent(schemaObject.name, 1), indent(')')].join('\n')) + .string; + + case false: + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock([indent(`return z.object({`), 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, circularTypes); + return indent(`${field.name.value}: ${maybeLazy(field.type, gen, config, circularTypes)}`, indentCount); +} + +function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, circularTypes: Set, parentType?: TypeNode): string { + if (isListType(type)) { + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, circularTypes, type); + 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, circularTypes, type); + 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) { + 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()'; +} + +function buildSchemaObject(name: string, discriminator: string, typeName: string, shape: string | undefined) { + const objectName = name.charAt(0).toLowerCase() + name.slice(1) + 'SchemaObject' + return { + string: `export const ${objectName}: Properties<${typeName}> = {\n${discriminator}\n${shape}\n}\n\n`, + name: objectName, + } +} +