diff --git a/package.json b/package.json index 300bb1e3..cf1d11ef 100644 --- a/package.json +++ b/package.json @@ -58,11 +58,11 @@ ], "scripts": { "type-check": "tsc --noEmit", - "type-check:yup": "tsc --strict --skipLibCheck --noEmit example/yup/schemas.ts", - "type-check:zod": "tsc --strict --skipLibCheck --noEmit example/zod/schemas.ts", - "type-check:zodv4": "tsc --strict --skipLibCheck --noEmit example/zodv4/schemas.ts", - "type-check:myzod": "tsc --strict --skipLibCheck --noEmit example/myzod/schemas.ts", - "type-check:valibot": "tsc --strict --skipLibCheck --noEmit example/valibot/schemas.ts", + "type-check:yup": "tsc --strict --skipLibCheck --noEmit --ignoreConfig example/yup/schemas.ts", + "type-check:zod": "tsc --strict --skipLibCheck --noEmit --ignoreConfig example/zod/schemas.ts", + "type-check:zodv4": "tsc --strict --skipLibCheck --noEmit --ignoreConfig example/zodv4/schemas.ts", + "type-check:myzod": "tsc --strict --skipLibCheck --noEmit --ignoreConfig example/myzod/schemas.ts", + "type-check:valibot": "tsc --strict --skipLibCheck --noEmit --ignoreConfig example/valibot/schemas.ts", "test": "vitest run", "build": "run-p build:*", "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", diff --git a/src/lazy.ts b/src/lazy.ts new file mode 100644 index 00000000..0e17dd4e --- /dev/null +++ b/src/lazy.ts @@ -0,0 +1,29 @@ +import type { TypeNode } from 'graphql'; +import type { Visitor } from './visitor.js'; + +import { isEnumType, isScalarType } from 'graphql'; +import { isNamedType } from './graphql.js'; + +/** + * Wraps a schema expression in a library-specific lazy reference when the type + * is a complex (non-scalar, non-enum) named type — avoiding issues with + * mutually-recursive input types. + * + * Each validation library has its own lazy syntax (z.lazy, v.lazy, etc.), so + * callers supply the wrapper function. + * + * @param lazyWrapper - e.g. `(s) => \`z.lazy(() => ${s})\`` + */ +export function buildMaybeLazy( + visitor: Visitor, + type: TypeNode, + schema: string, + lazyWrapper: (schema: string) => string, +): string { + if (!isNamedType(type)) + return schema; + + const schemaType = visitor.getType(type.name.value); + const isComplexType = !isScalarType(schemaType) && !isEnumType(schemaType); + return isComplexType ? lazyWrapper(schema) : schema; +} diff --git a/src/myzod/index.ts b/src/myzod/index.ts index d84afce7..29557c31 100644 --- a/src/myzod/index.ts +++ b/src/myzod/index.ts @@ -16,8 +16,6 @@ import type { Visitor } from '../visitor.js'; import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers'; import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { - isEnumType, - isScalarType, Kind, } from 'graphql'; import { buildApi, formatDirectiveConfig } from '../directive.js'; @@ -29,6 +27,8 @@ import { isNonNullType, ObjectTypeDefinitionBuilder, } from '../graphql.js'; +import { buildMaybeLazy } from '../lazy.js'; +import { buildScalarSchema } from '../scalar.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; const anySchema = `definedNonNullAnySchema`; @@ -365,33 +365,12 @@ function generateNameNodeMyZodSchema(config: ValidationSchemaPluginConfig, visit } function maybeLazy(visitor: Visitor, type: TypeNode, schema: string): string { - if (!isNamedType(type)) { - return schema; - } - - const schemaType = visitor.getType(type.name.value); - const isComplexType = !isScalarType(schemaType) && !isEnumType(schemaType); - return isComplexType ? `myzod.lazy(() => ${schema})` : schema; + return buildMaybeLazy(visitor, type, schema, s => `myzod.lazy(() => ${s})`); } function myzod4Scalar(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 `myzod.string()`; - case 'number': - return `myzod.number()`; - case 'boolean': - return `myzod.boolean()`; - } - - if (config.defaultScalarTypeSchema) { - return config.defaultScalarTypeSchema; - } - - console.warn('unhandled name:', scalarName); - return anySchema; + return buildScalarSchema(config, visitor, scalarName, { + typeMap: { string: 'myzod.string()', number: 'myzod.number()', boolean: 'myzod.boolean()' }, + fallback: anySchema, + }); } diff --git a/src/scalar.ts b/src/scalar.ts new file mode 100644 index 00000000..66ddac6e --- /dev/null +++ b/src/scalar.ts @@ -0,0 +1,39 @@ +import type { ValidationSchemaPluginConfig } from './config.js'; +import type { Visitor } from './visitor.js'; + +/** + * Builds a library-specific scalar schema expression. + * + * All five validation libraries follow the same pattern: check for a custom + * `scalarSchemas` override, fall back to a built-in type map for the resolved + * TypeScript type (string/number/boolean), then apply `defaultScalarTypeSchema`, + * and finally warn and return the library fallback. + * + * The only per-library differences are the strings in `typeMap`, the `fallback` + * value, and whether custom schemas need wrapping (yup appends `.defined()`). + */ +export function buildScalarSchema( + config: ValidationSchemaPluginConfig, + visitor: Visitor, + scalarName: string, + options: { + typeMap: Record<'string' | 'number' | 'boolean', string> + fallback: string + wrapCustom?: (schema: string) => string + }, +): string { + if (config.scalarSchemas?.[scalarName]) { + const custom = config.scalarSchemas[scalarName]; + return options.wrapCustom ? options.wrapCustom(custom) : custom; + } + + const tsType = visitor.getScalarType(scalarName); + if (tsType != null && tsType in options.typeMap) + return options.typeMap[tsType as keyof typeof options.typeMap]; + + if (config.defaultScalarTypeSchema) + return config.defaultScalarTypeSchema; + + console.warn('unhandled scalar name:', scalarName); + return options.fallback; +} diff --git a/src/valibot/index.ts b/src/valibot/index.ts index c52cdd65..058dc799 100644 --- a/src/valibot/index.ts +++ b/src/valibot/index.ts @@ -14,7 +14,6 @@ import type { ValidationSchemaPluginConfig } from '../config.js'; import type { Visitor } from '../visitor.js'; import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; -import { isEnumType, isScalarType } from 'graphql'; import { buildApiForValibot, formatDirectiveConfig } from '../directive.js'; import { InterfaceTypeDefinitionBuilder, @@ -23,6 +22,8 @@ import { isNonNullType, ObjectTypeDefinitionBuilder, } from '../graphql.js'; +import { buildMaybeLazy } from '../lazy.js'; +import { buildScalarSchema } from '../scalar.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; export class ValibotSchemaVisitor extends BaseSchemaVisitor { @@ -287,33 +288,12 @@ function generateNameNodeValibotSchema(config: ValidationSchemaPluginConfig, vis } function maybeLazy(visitor: Visitor, type: TypeNode, schema: string): string { - if (!isNamedType(type)) { - return schema; - } - - const schemaType = visitor.getType(type.name.value); - const isComplexType = !isScalarType(schemaType) && !isEnumType(schemaType); - return isComplexType ? `v.lazy(() => ${schema})` : schema; + return buildMaybeLazy(visitor, type, schema, s => `v.lazy(() => ${s})`); } function valibot4Scalar(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 `v.string()`; - case 'number': - return `v.number()`; - case 'boolean': - return `v.boolean()`; - } - - if (config.defaultScalarTypeSchema) { - return config.defaultScalarTypeSchema; - } - - console.warn('unhandled scalar name:', scalarName); - return 'v.any()'; + return buildScalarSchema(config, visitor, scalarName, { + typeMap: { string: 'v.string()', number: 'v.number()', boolean: 'v.boolean()' }, + fallback: 'v.any()', + }); } diff --git a/src/yup/index.ts b/src/yup/index.ts index 70302adc..506c767d 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -16,8 +16,6 @@ import type { Visitor } from '../visitor.js'; import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers'; import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { - isEnumType, - isScalarType, Kind, } from 'graphql'; import { buildApi, formatDirectiveConfig } from '../directive.js'; @@ -29,6 +27,8 @@ import { isNonNullType, ObjectTypeDefinitionBuilder, } from '../graphql.js'; +import { buildMaybeLazy } from '../lazy.js'; +import { buildScalarSchema } from '../scalar.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; export class YupSchemaVisitor extends BaseSchemaVisitor { @@ -391,33 +391,13 @@ function generateNameNodeYupSchema(config: ValidationSchemaPluginConfig, visitor } function maybeLazy(visitor: Visitor, type: TypeNode, schema: string): string { - if (!isNamedType(type)) { - return schema; - } - - const schemaType = visitor.getType(type.name.value); - const isComplexType = !isScalarType(schemaType) && !isEnumType(schemaType); - return isComplexType ? `yup.lazy(() => ${schema})` : schema; + return buildMaybeLazy(visitor, type, schema, s => `yup.lazy(() => ${s})`); } function yup4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scalarName: string): string { - if (config.scalarSchemas?.[scalarName]) - return `${config.scalarSchemas[scalarName]}.defined()`; - - const tsType = visitor.getScalarType(scalarName); - switch (tsType) { - case 'string': - return `yup.string().defined()`; - case 'number': - return `yup.number().defined()`; - case 'boolean': - return `yup.boolean().defined()`; - } - - if (config.defaultScalarTypeSchema) { - return config.defaultScalarTypeSchema - } - - console.warn('unhandled name:', scalarName); - return `yup.mixed()`; + return buildScalarSchema(config, visitor, scalarName, { + typeMap: { string: 'yup.string().defined()', number: 'yup.number().defined()', boolean: 'yup.boolean().defined()' }, + fallback: 'yup.mixed()', + wrapCustom: s => `${s}.defined()`, + }); } diff --git a/src/zod/index.ts b/src/zod/index.ts index 426b3bc3..dfbeee37 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -1,43 +1,37 @@ import type { Types } from '@graphql-codegen/plugin-helpers'; import type { - ConstValueNode, EnumTypeDefinitionNode, FieldDefinitionNode, GraphQLSchema, InputObjectTypeDefinitionNode, - InputObjectTypeExtensionNode, 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 { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { - isEnumType, - isInputObjectType, - isScalarType, - Kind, - valueFromASTUntyped, -} from 'graphql'; -import { buildApi, formatDirectiveConfig } from '../directive.js'; -import { - escapeGraphQLCharacters, InterfaceTypeDefinitionBuilder, - isListType, - isNamedType, - isNonNullType, ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; import { buildZodOperationSchemas } from './operation.js'; - -const anySchema = `definedNonNullAnySchema`; +import { + anySchema, + buildObjectExpression, + buildObjectReturn, + generateFieldTypeZodSchema, + generateFieldZodSchema, + isOneOfInputObject, + maybeLazy, + schemaDepthParameter, + schemaDepthVariable, + unionLiterals, + withDescription, +} from './shared.js'; export class ZodSchemaVisitor extends BaseSchemaVisitor { constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { @@ -324,273 +318,3 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { } } } - -function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, depthVariable?: string): string { - const gen = generateFieldTypeZodSchema(config, visitor, field, field.type, undefined, true, false, depthVariable); - return indent(`${field.name.value}: ${withDescription(config, field, maybeLazy(visitor, field.type, gen))}`, indentCount); -} - -function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode, isRoot = true, forceRequired = false, depthVariable?: string): string { - if (isListType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, false, false, depthVariable); - const arrayGen = `z.array(${maybeLazy(visitor, type.type, gen)})`; - const maybeDirectivesGen = isRoot ? applyDirectives(config, field, arrayGen) : arrayGen; - const maybeDefaultGen = hasNullDefault(field) ? maybeDirectivesGen : applyDefaultValue(config, visitor, field, type, maybeDirectivesGen); - if (!isNonNullType(parentType) && !forceRequired) { - if (hasNullDefault(field)) - return withNullDefault(config, maybeDirectivesGen); - - return `${maybeDefaultGen}.${zodOptionalType(config)}()`; - } - return maybeDefaultGen; - } - if (isNonNullType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, isRoot, forceRequired, depthVariable); - return maybeLazy(visitor, type.type, gen); - } - if (isNamedType(type)) { - const gen = generateNameNodeZodSchema(config, visitor, type.name, depthVariable); - if (isListType(parentType)) - return `${gen}.nullable()`; - - const appliedDirectivesGen = isRoot - ? hasNullDefault(field) - ? applyDirectives(config, field, gen) - : applyDefaultValue(config, visitor, field, type, applyDirectives(config, field, gen)) - : gen; - - if (isNonNullType(parentType)) { - if (visitor.shouldEmitAsNotAllowEmptyString(type.name.value)) - return `${appliedDirectivesGen}.min(1)`; - - return appliedDirectivesGen; - } - if (isListType(parentType)) - return `${appliedDirectivesGen}.nullable()`; - - if (forceRequired) - return appliedDirectivesGen; - - return hasNullDefault(field) - ? withNullDefault(config, appliedDirectivesGen) - : `${appliedDirectivesGen}.${zodOptionalType(config)}()`; - } - console.warn('unhandled type:', type); - return ''; -} - -function isOneOfInputObject(node: InputObjectTypeDefinitionNode): boolean { - return node.directives?.some(directive => directive.name.value === 'oneOf') === true; -} - -function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string | undefined): string { - return ['z.object({', shape, `})${strictObjectSuffix(config)}`].join('\n'); -} - -function buildObjectReturn(config: ValidationSchemaPluginConfig, shape: string | undefined): string { - return [indent('return z.object({'), shape, indent(`})${strictObjectSuffix(config)}`)].join('\n'); -} - -function strictObjectSuffix(config: ValidationSchemaPluginConfig): string { - return config.strictObjectSchemas === true ? '.strict()' : ''; -} - -function zodOptionalType(config: ValidationSchemaPluginConfig): string { - return config.nullishBehavior ?? config.zodOptionalType ?? 'nullish'; -} - -function withNullDefault(config: ValidationSchemaPluginConfig, gen: string): string { - if (zodOptionalType(config) === 'optional') - return `${gen}.nullable().optional().default(null)`; - - return `${gen}.${zodOptionalType(config)}().default(null)`; -} - -function schemaDepthVariable(config: ValidationSchemaPluginConfig): string | undefined { - return typeof config.maxDepth === 'number' && config.validationSchemaExportType !== 'const' - ? 'depth' - : undefined; -} - -function schemaDepthParameter(config: ValidationSchemaPluginConfig): string { - return schemaDepthVariable(config) ? 'depth = 0' : ''; -} - -function withDescription(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode, gen: string): string { - if (config.withDescriptions !== true || !field.description?.value) - return gen; - - return `${gen}.describe(${JSON.stringify(field.description.value)})`; -} - -function applyDefaultValue(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, gen: string): string { - if (field.kind !== Kind.INPUT_VALUE_DEFINITION || !field.defaultValue) - return gen; - - return `${gen}.default(${defaultValueExpression(config, visitor, type, field.defaultValue)})`; -} - -function defaultValueExpression(config: ValidationSchemaPluginConfig, visitor: Visitor, type: TypeNode, value: ConstValueNode): string { - if (value.kind === Kind.NULL) - return 'null'; - - if (isNonNullType(type)) - return defaultValueExpression(config, visitor, type.type, value); - - if (isListType(type)) { - if (value.kind === Kind.LIST) - return `[${value.values.map(item => defaultValueExpression(config, visitor, type.type, item)).join(', ')}]`; - - return `[${defaultValueExpression(config, visitor, type.type, value)}]`; - } - - if (isNamedType(type) && visitor.getType(type.name.value)?.astNode?.kind === 'EnumTypeDefinition' && value.kind === Kind.ENUM) { - if (!config.enumsAsTypes) - return `${enumDefaultTypeName(visitor, type)}.${enumDefaultValueName(config, value.value)}`; - - return JSON.stringify(value.value); - } - - if (isNamedType(type) && value.kind === Kind.OBJECT) { - const graphQLType = visitor.getType(type.name.value); - const astNode = graphQLType?.astNode; - if (astNode?.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && isInputObjectType(graphQLType)) { - const explicitFields = new Map(value.fields.map(field => [field.name.value, field.value])); - const fields = inputObjectFields(astNode, graphQLType.extensionASTNodes).flatMap((field) => { - const fieldValue = explicitFields.get(field.name.value) ?? field.defaultValue; - if (!fieldValue) - return []; - - return `${field.name.value}: ${defaultValueExpression(config, visitor, field.type, fieldValue)}`; - }); - - return `{ ${fields.join(', ')} }`; - } - } - - if (value.kind === Kind.INT || value.kind === Kind.FLOAT || value.kind === Kind.BOOLEAN) - return `${value.value}`; - - if (value.kind === Kind.STRING) - return `"${escapeGraphQLCharacters(value.value)}"`; - - return JSON.stringify(valueFromASTUntyped(value)); -} - -function hasNullDefault(field: InputValueDefinitionNode | FieldDefinitionNode): boolean { - return field.kind === Kind.INPUT_VALUE_DEFINITION && field.defaultValue?.kind === Kind.NULL; -} - -function inputObjectFields( - astNode: InputObjectTypeDefinitionNode, - extensionASTNodes: readonly InputObjectTypeExtensionNode[] | undefined, -): InputValueDefinitionNode[] { - return [ - ...(astNode.fields ?? []), - ...(extensionASTNodes?.flatMap(extension => extension.fields ?? []) ?? []), - ]; -} - -function enumDefaultTypeName(visitor: Visitor, type: TypeNode): string { - if (isNonNullType(type)) - return enumDefaultTypeName(visitor, type.type); - - if (isNamedType(type)) - return visitor.prefixTypeNamespace(visitor.convertSchemaName(type.name.value, visitor.getType(type.name.value)?.astNode?.kind)); - - return ''; -} - -function enumDefaultValueName(config: ValidationSchemaPluginConfig, value: string): string { - let enumValue = convertNameParts(value, resolveExternalModuleAndFn('change-case-all#pascalCase'), config.namingConvention?.transformUnderscore); - - if (config.namingConvention?.enumValues) - enumValue = convertNameParts(value, resolveExternalModuleAndFn(config.namingConvention?.enumValues), config.namingConvention?.transformUnderscore); - - return enumValue; -} - -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, depthVariable?: string): 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: - if ( - depthVariable - && ( - converter.targetKind === 'InterfaceTypeDefinition' - || converter.targetKind === 'ObjectTypeDefinition' - || converter.targetKind === 'UnionTypeDefinition' - ) - ) { - return `${depthVariable} >= ${config.maxDepth} ? ${anySchema} : ${converter.convertName()}Schema(${depthVariable} + 1)`; - } - 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(visitor: Visitor, type: TypeNode, schema: string): string { - if (!isNamedType(type)) { - return schema; - } - - const schemaType = visitor.getType(type.name.value); - const isComplexType = !isScalarType(schemaType) && !isEnumType(schemaType); - return isComplexType ? `z.lazy(() => ${schema})` : 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 anySchema; -} - -function unionLiterals(values: string[]): string { - if (values.length === 0) - return 'never'; - - return values.map(value => JSON.stringify(value)).join(' | '); -} diff --git a/src/zod/shared.ts b/src/zod/shared.ts new file mode 100644 index 00000000..2742011f --- /dev/null +++ b/src/zod/shared.ts @@ -0,0 +1,283 @@ +// Shared utilities used by both ZodSchemaVisitor (zod) and ZodV4SchemaVisitor (zodv4). +// These functions are identical across both implementations and are extracted here to +// eliminate duplication. + +import type { + ConstValueNode, + FieldDefinitionNode, + InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, + InputValueDefinitionNode, + NameNode, + TypeNode, +} from 'graphql'; + +import type { ValidationSchemaPluginConfig } from '../config.js'; +import type { Visitor } from '../visitor.js'; +import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers'; +import { convertNameParts, indent } from '@graphql-codegen/visitor-plugin-common'; +import { + isInputObjectType, + Kind, + valueFromASTUntyped, +} from 'graphql'; +import { buildApi, formatDirectiveConfig } from '../directive.js'; +import { + escapeGraphQLCharacters, + isListType, + isNamedType, + isNonNullType, +} from '../graphql.js'; +import { buildMaybeLazy } from '../lazy.js'; +import { buildScalarSchema } from '../scalar.js'; + +export const anySchema = `definedNonNullAnySchema`; + +export function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, depthVariable?: string): string { + const gen = generateFieldTypeZodSchema(config, visitor, field, field.type, undefined, true, false, depthVariable); + return indent(`${field.name.value}: ${withDescription(config, field, maybeLazy(visitor, field.type, gen))}`, indentCount); +} + +export function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode, isRoot = true, forceRequired = false, depthVariable?: string): string { + if (isListType(type)) { + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, false, false, depthVariable); + const arrayGen = `z.array(${maybeLazy(visitor, type.type, gen)})`; + const maybeDirectivesGen = isRoot ? applyDirectives(config, field, arrayGen) : arrayGen; + const maybeDefaultGen = hasNullDefault(field) ? maybeDirectivesGen : applyDefaultValue(config, visitor, field, type, maybeDirectivesGen); + if (!isNonNullType(parentType) && !forceRequired) { + if (hasNullDefault(field)) + return withNullDefault(config, maybeDirectivesGen); + + return `${maybeDefaultGen}.${zodOptionalType(config)}()`; + } + return maybeDefaultGen; + } + if (isNonNullType(type)) { + const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, isRoot, forceRequired, depthVariable); + return maybeLazy(visitor, type.type, gen); + } + if (isNamedType(type)) { + const gen = generateNameNodeZodSchema(config, visitor, type.name, depthVariable); + if (isListType(parentType)) + return `${gen}.nullable()`; + + const appliedDirectivesGen = isRoot + ? hasNullDefault(field) + ? applyDirectives(config, field, gen) + : applyDefaultValue(config, visitor, field, type, applyDirectives(config, field, gen)) + : gen; + + if (isNonNullType(parentType)) { + if (visitor.shouldEmitAsNotAllowEmptyString(type.name.value)) + return `${appliedDirectivesGen}.min(1)`; + + return appliedDirectivesGen; + } + if (isListType(parentType)) + return `${appliedDirectivesGen}.nullable()`; + + if (forceRequired) + return appliedDirectivesGen; + + return hasNullDefault(field) + ? withNullDefault(config, appliedDirectivesGen) + : `${appliedDirectivesGen}.${zodOptionalType(config)}()`; + } + console.warn('unhandled type:', type); + return ''; +} + +export function isOneOfInputObject(node: InputObjectTypeDefinitionNode): boolean { + return node.directives?.some(directive => directive.name.value === 'oneOf') === true; +} + +export function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string | undefined): string { + return ['z.object({', shape, `})${strictObjectSuffix(config)}`].join('\n'); +} + +export function buildObjectReturn(config: ValidationSchemaPluginConfig, shape: string | undefined): string { + return [indent('return z.object({'), shape, indent(`})${strictObjectSuffix(config)}`)].join('\n'); +} + +export function strictObjectSuffix(config: ValidationSchemaPluginConfig): string { + return config.strictObjectSchemas === true ? '.strict()' : ''; +} + +export function zodOptionalType(config: ValidationSchemaPluginConfig): string { + return config.nullishBehavior ?? config.zodOptionalType ?? 'nullish'; +} + +export function withNullDefault(config: ValidationSchemaPluginConfig, gen: string): string { + if (zodOptionalType(config) === 'optional') + return `${gen}.nullable().optional().default(null)`; + + return `${gen}.${zodOptionalType(config)}().default(null)`; +} + +export function schemaDepthVariable(config: ValidationSchemaPluginConfig): string | undefined { + return typeof config.maxDepth === 'number' && config.validationSchemaExportType !== 'const' + ? 'depth' + : undefined; +} + +export function schemaDepthParameter(config: ValidationSchemaPluginConfig): string { + return schemaDepthVariable(config) ? 'depth = 0' : ''; +} + +export function withDescription(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode, gen: string): string { + if (config.withDescriptions !== true || !field.description?.value) + return gen; + + return `${gen}.describe(${JSON.stringify(field.description.value)})`; +} + +export function applyDefaultValue(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, gen: string): string { + if (field.kind !== Kind.INPUT_VALUE_DEFINITION || !field.defaultValue) + return gen; + + return `${gen}.default(${defaultValueExpression(config, visitor, type, field.defaultValue)})`; +} + +export function defaultValueExpression(config: ValidationSchemaPluginConfig, visitor: Visitor, type: TypeNode, value: ConstValueNode): string { + if (value.kind === Kind.NULL) + return 'null'; + + if (isNonNullType(type)) + return defaultValueExpression(config, visitor, type.type, value); + + if (isListType(type)) { + if (value.kind === Kind.LIST) + return `[${value.values.map(item => defaultValueExpression(config, visitor, type.type, item)).join(', ')}]`; + + return `[${defaultValueExpression(config, visitor, type.type, value)}]`; + } + + if (isNamedType(type) && visitor.getType(type.name.value)?.astNode?.kind === 'EnumTypeDefinition' && value.kind === Kind.ENUM) { + if (!config.enumsAsTypes) + return `${enumDefaultTypeName(visitor, type)}.${enumDefaultValueName(config, value.value)}`; + + return JSON.stringify(value.value); + } + + if (isNamedType(type) && value.kind === Kind.OBJECT) { + const graphQLType = visitor.getType(type.name.value); + const astNode = graphQLType?.astNode; + if (astNode?.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && isInputObjectType(graphQLType)) { + const explicitFields = new Map(value.fields.map(field => [field.name.value, field.value])); + const fields = inputObjectFields(astNode, graphQLType.extensionASTNodes).flatMap((field) => { + const fieldValue = explicitFields.get(field.name.value) ?? field.defaultValue; + if (!fieldValue) + return []; + + return `${field.name.value}: ${defaultValueExpression(config, visitor, field.type, fieldValue)}`; + }); + + return `{ ${fields.join(', ')} }`; + } + } + + if (value.kind === Kind.INT || value.kind === Kind.FLOAT || value.kind === Kind.BOOLEAN) + return `${value.value}`; + + if (value.kind === Kind.STRING) + return `"${escapeGraphQLCharacters(value.value)}"`; + + return JSON.stringify(valueFromASTUntyped(value)); +} + +export function hasNullDefault(field: InputValueDefinitionNode | FieldDefinitionNode): boolean { + return field.kind === Kind.INPUT_VALUE_DEFINITION && field.defaultValue?.kind === Kind.NULL; +} + +export function inputObjectFields( + astNode: InputObjectTypeDefinitionNode, + extensionASTNodes: readonly InputObjectTypeExtensionNode[] | undefined, +): InputValueDefinitionNode[] { + return [ + ...(astNode.fields ?? []), + ...(extensionASTNodes?.flatMap(extension => extension.fields ?? []) ?? []), + ]; +} + +export function enumDefaultTypeName(visitor: Visitor, type: TypeNode): string { + if (isNonNullType(type)) + return enumDefaultTypeName(visitor, type.type); + + if (isNamedType(type)) + return visitor.prefixTypeNamespace(visitor.convertSchemaName(type.name.value, visitor.getType(type.name.value)?.astNode?.kind)); + + return ''; +} + +export function enumDefaultValueName(config: ValidationSchemaPluginConfig, value: string): string { + let enumValue = convertNameParts(value, resolveExternalModuleAndFn('change-case-all#pascalCase'), config.namingConvention?.transformUnderscore); + + if (config.namingConvention?.enumValues) + enumValue = convertNameParts(value, resolveExternalModuleAndFn(config.namingConvention?.enumValues), config.namingConvention?.transformUnderscore); + + return enumValue; +} + +export 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; +} + +export function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode, depthVariable?: string): 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: + if ( + depthVariable + && ( + converter.targetKind === 'InterfaceTypeDefinition' + || converter.targetKind === 'ObjectTypeDefinition' + || converter.targetKind === 'UnionTypeDefinition' + ) + ) { + return `${depthVariable} >= ${config.maxDepth} ? ${anySchema} : ${converter.convertName()}Schema(${depthVariable} + 1)`; + } + 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); + } +} + +export function maybeLazy(visitor: Visitor, type: TypeNode, schema: string): string { + return buildMaybeLazy(visitor, type, schema, s => `z.lazy(() => ${s})`); +} + +export function zod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scalarName: string): string { + return buildScalarSchema(config, visitor, scalarName, { + typeMap: { string: 'z.string()', number: 'z.number()', boolean: 'z.boolean()' }, + fallback: anySchema, + }); +} + +export function unionLiterals(values: string[]): string { + if (values.length === 0) + return 'never'; + + return values.map(value => JSON.stringify(value)).join(' | '); +} diff --git a/src/zodv4/index.ts b/src/zodv4/index.ts index 300be9a6..4c872da6 100644 --- a/src/zodv4/index.ts +++ b/src/zodv4/index.ts @@ -1,43 +1,38 @@ import type { Types } from '@graphql-codegen/plugin-helpers'; import type { - ConstValueNode, EnumTypeDefinitionNode, FieldDefinitionNode, GraphQLSchema, InputObjectTypeDefinitionNode, - InputObjectTypeExtensionNode, 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 { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; +import { Kind } from 'graphql'; import { - isEnumType, - isInputObjectType, - isScalarType, - Kind, - valueFromASTUntyped, -} from 'graphql'; -import { buildApi, formatDirectiveConfig } from '../directive.js'; -import { - escapeGraphQLCharacters, InterfaceTypeDefinitionBuilder, - isListType, - isNamedType, - isNonNullType, ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; import { buildZodOperationSchemas } from '../zod/operation.js'; - -const anySchema = `definedNonNullAnySchema`; +import { + anySchema, + buildObjectExpression, + buildObjectReturn, + generateFieldTypeZodSchema, + generateFieldZodSchema, + isOneOfInputObject, + maybeLazy, + schemaDepthParameter, + schemaDepthVariable, + unionLiterals, + withDescription, +} from '../zod/shared.js'; export class ZodV4SchemaVisitor extends BaseSchemaVisitor { constructor(schema: GraphQLSchema, config: ValidationSchemaPluginConfig) { @@ -326,276 +321,6 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { } } -function generateFieldZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, indentCount: number, depthVariable?: string): string { - const gen = generateFieldTypeZodSchema(config, visitor, field, field.type, undefined, true, false, depthVariable); - return indent(`${field.name.value}: ${withDescription(config, field, maybeLazy(visitor, field.type, gen))}`, indentCount); -} - -function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode, isRoot = true, forceRequired = false, depthVariable?: string): string { - if (isListType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, false, false, depthVariable); - const arrayGen = `z.array(${maybeLazy(visitor, type.type, gen)})`; - const maybeDirectivesGen = isRoot ? applyDirectives(config, field, arrayGen) : arrayGen; - const maybeDefaultGen = hasNullDefault(field) ? maybeDirectivesGen : applyDefaultValue(config, visitor, field, type, maybeDirectivesGen); - if (!isNonNullType(parentType) && !forceRequired) { - if (hasNullDefault(field)) - return withNullDefault(config, maybeDirectivesGen); - - return `${maybeDefaultGen}.${zodOptionalType(config)}()`; - } - return maybeDefaultGen; - } - if (isNonNullType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, type, isRoot, forceRequired, depthVariable); - return maybeLazy(visitor, type.type, gen); - } - if (isNamedType(type)) { - const gen = generateNameNodeZodSchema(config, visitor, type.name, depthVariable); - if (isListType(parentType)) - return `${gen}.nullable()`; - - const appliedDirectivesGen = isRoot - ? hasNullDefault(field) - ? applyDirectives(config, field, gen) - : applyDefaultValue(config, visitor, field, type, applyDirectives(config, field, gen)) - : gen; - - if (isNonNullType(parentType)) { - if (visitor.shouldEmitAsNotAllowEmptyString(type.name.value)) - return `${appliedDirectivesGen}.min(1)`; - - return appliedDirectivesGen; - } - if (isListType(parentType)) - return `${appliedDirectivesGen}.nullable()`; - - if (forceRequired) - return appliedDirectivesGen; - - return hasNullDefault(field) - ? withNullDefault(config, appliedDirectivesGen) - : `${appliedDirectivesGen}.${zodOptionalType(config)}()`; - } - console.warn('unhandled type:', type); - return ''; -} - -function isOneOfInputObject(node: InputObjectTypeDefinitionNode): boolean { - return node.directives?.some(directive => directive.name.value === 'oneOf') === true; -} - function hasDefaultValue(fields: readonly (FieldDefinitionNode | InputValueDefinitionNode)[]): boolean { return fields.some(field => field.kind === Kind.INPUT_VALUE_DEFINITION && field.defaultValue !== undefined); } - -function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string | undefined): string { - return ['z.object({', shape, `})${strictObjectSuffix(config)}`].join('\n'); -} - -function buildObjectReturn(config: ValidationSchemaPluginConfig, shape: string | undefined): string { - return [indent('return z.object({'), shape, indent(`})${strictObjectSuffix(config)}`)].join('\n'); -} - -function strictObjectSuffix(config: ValidationSchemaPluginConfig): string { - return config.strictObjectSchemas === true ? '.strict()' : ''; -} - -function zodOptionalType(config: ValidationSchemaPluginConfig): string { - return config.nullishBehavior ?? config.zodOptionalType ?? 'nullish'; -} - -function withNullDefault(config: ValidationSchemaPluginConfig, gen: string): string { - if (zodOptionalType(config) === 'optional') - return `${gen}.nullable().optional().default(null)`; - - return `${gen}.${zodOptionalType(config)}().default(null)`; -} - -function schemaDepthVariable(config: ValidationSchemaPluginConfig): string | undefined { - return typeof config.maxDepth === 'number' && config.validationSchemaExportType !== 'const' - ? 'depth' - : undefined; -} - -function schemaDepthParameter(config: ValidationSchemaPluginConfig): string { - return schemaDepthVariable(config) ? 'depth = 0' : ''; -} - -function withDescription(config: ValidationSchemaPluginConfig, field: InputValueDefinitionNode | FieldDefinitionNode, gen: string): string { - if (config.withDescriptions !== true || !field.description?.value) - return gen; - - return `${gen}.describe(${JSON.stringify(field.description.value)})`; -} - -function applyDefaultValue(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, gen: string): string { - if (field.kind !== Kind.INPUT_VALUE_DEFINITION || !field.defaultValue) - return gen; - - return `${gen}.default(${defaultValueExpression(config, visitor, type, field.defaultValue)})`; -} - -function defaultValueExpression(config: ValidationSchemaPluginConfig, visitor: Visitor, type: TypeNode, value: ConstValueNode): string { - if (value.kind === Kind.NULL) - return 'null'; - - if (isNonNullType(type)) - return defaultValueExpression(config, visitor, type.type, value); - - if (isListType(type)) { - if (value.kind === Kind.LIST) - return `[${value.values.map(item => defaultValueExpression(config, visitor, type.type, item)).join(', ')}]`; - - return `[${defaultValueExpression(config, visitor, type.type, value)}]`; - } - - if (isNamedType(type) && visitor.getType(type.name.value)?.astNode?.kind === 'EnumTypeDefinition' && value.kind === Kind.ENUM) { - if (!config.enumsAsTypes) - return `${enumDefaultTypeName(visitor, type)}.${enumDefaultValueName(config, value.value)}`; - - return JSON.stringify(value.value); - } - - if (isNamedType(type) && value.kind === Kind.OBJECT) { - const graphQLType = visitor.getType(type.name.value); - const astNode = graphQLType?.astNode; - if (astNode?.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && isInputObjectType(graphQLType)) { - const explicitFields = new Map(value.fields.map(field => [field.name.value, field.value])); - const fields = inputObjectFields(astNode, graphQLType.extensionASTNodes).flatMap((field) => { - const fieldValue = explicitFields.get(field.name.value) ?? field.defaultValue; - if (!fieldValue) - return []; - - return `${field.name.value}: ${defaultValueExpression(config, visitor, field.type, fieldValue)}`; - }); - - return `{ ${fields.join(', ')} }`; - } - } - - if (value.kind === Kind.INT || value.kind === Kind.FLOAT || value.kind === Kind.BOOLEAN) - return `${value.value}`; - - if (value.kind === Kind.STRING) - return `"${escapeGraphQLCharacters(value.value)}"`; - - return JSON.stringify(valueFromASTUntyped(value)); -} - -function hasNullDefault(field: InputValueDefinitionNode | FieldDefinitionNode): boolean { - return field.kind === Kind.INPUT_VALUE_DEFINITION && field.defaultValue?.kind === Kind.NULL; -} - -function inputObjectFields( - astNode: InputObjectTypeDefinitionNode, - extensionASTNodes: readonly InputObjectTypeExtensionNode[] | undefined, -): InputValueDefinitionNode[] { - return [ - ...(astNode.fields ?? []), - ...(extensionASTNodes?.flatMap(extension => extension.fields ?? []) ?? []), - ]; -} - -function enumDefaultTypeName(visitor: Visitor, type: TypeNode): string { - if (isNonNullType(type)) - return enumDefaultTypeName(visitor, type.type); - - if (isNamedType(type)) - return visitor.prefixTypeNamespace(visitor.convertSchemaName(type.name.value, visitor.getType(type.name.value)?.astNode?.kind)); - - return ''; -} - -function enumDefaultValueName(config: ValidationSchemaPluginConfig, value: string): string { - let enumValue = convertNameParts(value, resolveExternalModuleAndFn('change-case-all#pascalCase'), config.namingConvention?.transformUnderscore); - - if (config.namingConvention?.enumValues) - enumValue = convertNameParts(value, resolveExternalModuleAndFn(config.namingConvention?.enumValues), config.namingConvention?.transformUnderscore); - - return enumValue; -} - -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, depthVariable?: string): 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: - if ( - depthVariable - && ( - converter.targetKind === 'InterfaceTypeDefinition' - || converter.targetKind === 'ObjectTypeDefinition' - || converter.targetKind === 'UnionTypeDefinition' - ) - ) { - return `${depthVariable} >= ${config.maxDepth} ? ${anySchema} : ${converter.convertName()}Schema(${depthVariable} + 1)`; - } - 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(visitor: Visitor, type: TypeNode, schema: string): string { - if (!isNamedType(type)) { - return schema; - } - - const schemaType = visitor.getType(type.name.value); - const isComplexType = !isScalarType(schemaType) && !isEnumType(schemaType); - return isComplexType ? `z.lazy(() => ${schema})` : 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 anySchema; -} - -function unionLiterals(values: string[]): string { - if (values.length === 0) - return 'never'; - - return values.map(value => JSON.stringify(value)).join(' | '); -} diff --git a/tests/helpers/plugin.ts b/tests/helpers/plugin.ts new file mode 100644 index 00000000..365856a5 --- /dev/null +++ b/tests/helpers/plugin.ts @@ -0,0 +1,19 @@ +import type { ValidationSchemaPluginConfig } from '../../src/config.js'; + +import { buildSchema } from 'graphql'; +import { plugin } from '../../src/index.js'; + +export type { ValidationSchemaPluginConfig }; + +/** + * Build a GraphQL schema from a SDL string and run the plugin with the given + * config. Reduces the boilerplate of `buildSchema` + `plugin(schema, [], config, {})` + * that appears identically in every spec file. + */ +export async function runPlugin( + schemaStr: string, + config: Partial = {}, +) { + const schema = buildSchema(schemaStr); + return plugin(schema, [], config, {}); +} diff --git a/tests/helpers/schemas.ts b/tests/helpers/schemas.ts new file mode 100644 index 00000000..a0d8ecfe --- /dev/null +++ b/tests/helpers/schemas.ts @@ -0,0 +1,87 @@ +// Common GraphQL schema strings shared across multiple spec files. +// Use these constants instead of re-declaring identical schema strings per test. + +export const PRIMITIVE_NON_NULL_SCHEMA = /* GraphQL */ ` + input PrimitiveInput { + a: ID! + b: String! + c: Boolean! + d: Int! + e: Float! + } +`; + +export const PRIMITIVE_NULLABLE_SCHEMA = /* GraphQL */ ` + input PrimitiveInput { + a: ID + b: String + c: Boolean + d: Int + e: Float + z: String! # no defined check + } +`; + +export const ARRAY_INPUT_SCHEMA = /* GraphQL */ ` + input ArrayInput { + a: [String] + b: [String!] + c: [String!]! + d: [[String]] + e: [[String]!] + f: [[String]!]! + } +`; + +export const REF_INPUT_SCHEMA = /* GraphQL */ ` + input AInput { + b: BInput! + } + input BInput { + c: CInput! + } + input CInput { + a: AInput! + } +`; + +export const NESTED_INPUT_SCHEMA = /* GraphQL */ ` + input NestedInput { + child: NestedInput + childrens: [NestedInput] + } +`; + +export const ENUM_SCHEMA = /* GraphQL */ ` + enum PageType { + PUBLIC + BASIC_AUTH + } + input PageInput { + pageType: PageType! + } +`; + +export const CAMELCASE_SCHEMA = /* GraphQL */ ` + input HTTPInput { + method: HTTPMethod + url: URL! + } + + enum HTTPMethod { + GET + POST + } + + scalar URL # unknown scalar, should be any (definedNonNullAnySchema) +`; + +export const SCALARS_SCHEMA = /* GraphQL */ ` + input Say { + phrase: Text! + times: Count! + } + + scalar Count + scalar Text +`; diff --git a/tests/myzod.spec.ts b/tests/myzod.spec.ts index b8e8f333..7c1f254f 100644 --- a/tests/myzod.spec.ts +++ b/tests/myzod.spec.ts @@ -2,6 +2,7 @@ import { buildClientSchema, buildSchema, introspectionFromSchema } from 'graphql import dedent from 'ts-dedent'; import { plugin } from '../src/index'; +import { expectTypeScriptToCompile } from './typescript-compile'; const initialEmitValue = dedent(` export const definedNonNullAnySchema = myzod.object({}); @@ -48,6 +49,19 @@ describe('myzod', () => { } " `) + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + type PrimitiveInput = { + a: string; + b: string; + c: boolean; + d: number; + e: number; + } + + ${result.content} + `); }); it('nullish', async () => { diff --git a/tests/valibot.spec.ts b/tests/valibot.spec.ts index 762f6e47..9cd51eb1 100644 --- a/tests/valibot.spec.ts +++ b/tests/valibot.spec.ts @@ -1,6 +1,7 @@ import { buildSchema } from 'graphql'; import { plugin } from '../src/index'; +import { expectTypeScriptToCompile } from './typescript-compile'; describe('valibot', () => { it('non-null and defined', async () => { @@ -31,6 +32,19 @@ describe('valibot', () => { } " `); + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + type PrimitiveInput = { + a: string; + b: string; + c: boolean; + d: number; + e: number; + } + + ${result.content} + `); }) it('nullish', async () => { const schema = buildSchema(/* GraphQL */ ` diff --git a/tests/yup.spec.ts b/tests/yup.spec.ts index c6483116..de620bc0 100644 --- a/tests/yup.spec.ts +++ b/tests/yup.spec.ts @@ -39,6 +39,19 @@ describe('yup', () => { } " `) + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + type PrimitiveInput = { + a: string; + b: string; + c: boolean; + d: number; + e: number; + } + + ${result.content} + `); }); it('optional', async () => {