From 9a451bc78e0d831dd6236201115254ff430bacc5 Mon Sep 17 00:00:00 2001 From: Kei Kamikawa Date: Sat, 25 Apr 2026 21:20:00 +0900 Subject: [PATCH] Resolve validation schema open issues --- README.md | 92 ++++ package.json | 4 +- pnpm-lock.yaml | 68 ++- src/config.ts | 63 ++- src/directive.ts | 63 ++- src/index.ts | 15 +- src/myzod/index.ts | 9 +- src/schema_visitor.ts | 31 +- src/types.ts | 2 + src/valibot/index.ts | 17 +- src/visitor.ts | 10 +- src/yup/index.ts | 9 +- src/zod/index.ts | 321 ++++++++--- src/zod/operation.ts | 359 ++++++++++++ src/zodv4/index.ts | 332 ++++++++--- tests/directive.spec.ts | 65 ++- tests/myzod.spec.ts | 56 ++ tests/typescript-compile.ts | 37 ++ tests/valibot.spec.ts | 57 +- tests/yup.spec.ts | 101 ++++ tests/zod.spec.ts | 1038 ++++++++++++++++++++++++++++++++++- tests/zodv4.spec.ts | 621 ++++++++++++++++----- 22 files changed, 3014 insertions(+), 356 deletions(-) create mode 100644 src/zod/operation.ts create mode 100644 tests/typescript-compile.ts diff --git a/README.md b/README.md index f07ab5f3..2b4cfcc5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,35 @@ You can check [example](https://github.com/Code-Hex/graphql-codegen-typescript-v The Q&A for each schema is written in the README in the respective example directory. +### TypeScript config and presets + +When you use `codegen.ts` with a preset such as `client`, put this plugin's options under the plugin entry. Some presets do not pass the shared generate-level `config` object to every plugin. + +```ts +import type { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + schema: 'schema.graphql', + generates: { + './src/gql/': { + preset: 'client', + plugins: [ + { + 'typescript-validation-schema': { + schema: 'zod', + scalarSchemas: { + DateTime: 'z.string().datetime()', + }, + }, + }, + ], + }, + }, +}; + +export default config; +``` + ## Config API Reference ### `schema` @@ -283,12 +312,75 @@ It is currently added for the purpose of using simple objects. See also [#20](ht This option currently **does not support fragment** generation. If you are interested, send me PR would be greatly appreciated! +### `withOperationType` + +type: `boolean` default: `false` + +Generates Zod schemas for GraphQL operation result selection sets. +Use this when `withObjectType` is too broad because it describes the full GraphQL object type, while your query only selects a subset of fields. + +```yml +config: + schema: zod + withOperationType: true +``` + +Currently supported for `schema: zod` and `schema: zodv4`. + +### `maxDepth` + +type: `number` + +Limits nested object validation depth for `withObjectType` schemas generated by `schema: zod` and `schema: zodv4`. +This is useful for cyclic output graphs where validating the entire object graph would recurse forever. + +```yml +config: + schema: zod + withObjectType: true + maxDepth: 1 +``` + ### `validationSchemaExportType` type: `ValidationSchemaExportType` default: `'function'` Specify validation schema export type. +### `zodOptionalType` + +type: `'nullish' | 'nullable' | 'optional'` default: `'nullish'` + +Controls how nullable GraphQL fields are generated for `schema: zod` and `schema: zodv4`. +The default `nullish` mode matches GraphQL input coercion, where a nullable input field may be omitted or passed as `null`. +Use `nullable` or `optional` only when your generated TypeScript `Maybe`/`InputMaybe` contract intentionally differs from GraphQL's default input coercion behavior. + +```yml +config: + schema: zod + zodOptionalType: nullable +``` + +`nullishBehavior` is also accepted as an alias. + +### `strictObjectSchemas` + +type: `boolean` default: `false` + +Appends `.strict()` to generated Zod object schemas. + +### `withDescriptions` + +type: `boolean` default: `false` + +Appends `.describe()` to generated Zod fields from GraphQL descriptions. + +### `onlyEnums` + +type: `boolean` default: `false` + +Generates only enum validation schemas. + ### `useEnumTypeAsDefaultValue` type: `boolean` default: `false` diff --git a/package.json b/package.json index 24781370..3eb7bf63 100644 --- a/package.json +++ b/package.json @@ -100,9 +100,9 @@ "ts-dedent": "^2.2.0", "ts-jest": "29.4.4", "typescript": "5.9.2", - "valibot": "1.1.0", + "valibot": "1.3.1", "vitest": "^3.0.0", "yup": "1.7.1", - "zod": "4.1.11" + "zod": "4.3.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 001bd1c4..f9245536 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,8 +67,8 @@ importers: specifier: 5.9.2 version: 5.9.2 valibot: - specifier: 1.1.0 - version: 1.1.0(typescript@5.9.2) + specifier: 1.3.1 + version: 1.3.1(typescript@5.9.2) vitest: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.6)(jiti@2.4.0)(yaml@2.8.1) @@ -76,8 +76,8 @@ importers: specifier: 1.7.1 version: 1.7.1 zod: - specifier: 4.1.11 - version: 4.1.11 + specifier: 4.3.6 + version: 4.3.6 packages: @@ -195,6 +195,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -213,6 +217,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-async-generators@7.8.4': resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -330,6 +339,10 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -2800,8 +2813,8 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -3229,6 +3242,10 @@ packages: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -3704,8 +3721,8 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} - valibot@1.1.0: - resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} + valibot@1.3.1: + resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -3900,8 +3917,8 @@ packages: yup@1.7.1: resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} - zod@4.1.11: - resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4052,6 +4069,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.27.6': @@ -4067,6 +4086,10 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.27.4)': dependencies: '@babel/core': 7.27.4 @@ -4189,6 +4212,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@clack/core@0.5.0': @@ -5512,7 +5540,7 @@ snapshots: '@vue/compiler-core@3.5.12': dependencies: - '@babel/parser': 7.28.4 + '@babel/parser': 7.29.2 '@vue/shared': 3.5.12 entities: 4.5.0 estree-walker: 2.0.2 @@ -5525,14 +5553,14 @@ snapshots: '@vue/compiler-sfc@3.5.12': dependencies: - '@babel/parser': 7.28.4 + '@babel/parser': 7.29.2 '@vue/compiler-core': 3.5.12 '@vue/compiler-dom': 3.5.12 '@vue/compiler-ssr': 3.5.12 '@vue/shared': 3.5.12 estree-walker: 2.0.2 - magic-string: 0.30.19 - postcss: 8.5.6 + magic-string: 0.30.21 + postcss: 8.5.10 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.12': @@ -7110,7 +7138,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - magic-string@0.30.19: + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7688,6 +7716,12 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -8148,7 +8182,7 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - valibot@1.1.0(typescript@5.9.2): + valibot@1.3.1(typescript@5.9.2): optionalDependencies: typescript: 5.9.2 @@ -8340,6 +8374,6 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zod@4.1.11: {} + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/src/config.ts b/src/config.ts index f75ab40f..0275899d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,15 +3,30 @@ import type { NamingConventionMap } from '@graphql-codegen/visitor-plugin-common export type ValidationSchema = 'yup' | 'zod' | 'zodv4' | 'myzod' | 'valibot'; export type ValidationSchemaExportType = 'function' | 'const'; +export type ZodOptionalType = 'nullish' | 'nullable' | 'optional'; + +export type DirectiveSchemaTemplate + = | string + | number + | boolean + | bigint + | null + | undefined + | ((...args: any[]) => any) + | DirectiveSchemaTemplate[] + | { [key: string]: DirectiveSchemaTemplate }; export interface DirectiveConfig { - [directive: string]: { - [argument: string]: string | string[] | DirectiveObjectArguments - } + [directive: string]: + | DirectiveSchemaTemplate + | DirectiveSchemaTemplate[] + | { + [argument: string]: DirectiveSchemaTemplate | DirectiveSchemaTemplate[] | DirectiveObjectArguments + } } export interface DirectiveObjectArguments { - [matched: string]: string | string[] + [matched: string]: DirectiveSchemaTemplate | DirectiveSchemaTemplate[] } interface ScalarSchemas { @@ -252,6 +267,14 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ withObjectType?: boolean + /** + * @description Generates validation schemas for GraphQL operation result selection sets. + * This is separate from withObjectType: object type schemas describe the full GraphQL type, + * while operation schemas describe only the fields selected by each operation document. + * Currently supported by zod and zodv4. + * @default false + */ + withOperationType?: boolean /** * @description Specify validation schema export type. * @default function @@ -268,6 +291,38 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ validationSchemaExportType?: ValidationSchemaExportType + /** + * @description Controls how nullable GraphQL fields are represented in Zod schemas. + * The default `nullish` mode matches GraphQL input coercion. `nullable` and `optional` + * are opt-in compatibility modes for projects that also customize their generated + * TypeScript Maybe/InputMaybe contracts. + * @default nullish + */ + zodOptionalType?: ZodOptionalType + /** + * @description Alias for zodOptionalType. + * The default `nullish` mode matches GraphQL input coercion. `nullable` and `optional` + * are opt-in compatibility modes for projects that also customize their generated + * TypeScript Maybe/InputMaybe contracts. + * @default nullish + */ + nullishBehavior?: ZodOptionalType + /** + * @description Appends `.strict()` to generated Zod object schemas. + * @default false + */ + strictObjectSchemas?: boolean + /** + * @description Appends `.describe()` calls from GraphQL descriptions in generated Zod schemas. + * @default false + */ + withDescriptions?: boolean + /** + * @description Maximum nested object depth to validate from generated object schemas. + * This is useful for cyclic output graphs. The option currently applies to zod and zodv4 + * object type schemas generated with withObjectType. + */ + maxDepth?: number /** * @description Uses the full path of the enum type as the default value instead of the stringified value. * @default false diff --git a/src/directive.ts b/src/directive.ts index 4b0641c6..9b36310e 100644 --- a/src/directive.ts +++ b/src/directive.ts @@ -1,5 +1,5 @@ import type { ConstArgumentNode, ConstDirectiveNode, ConstValueNode } from 'graphql'; -import type { DirectiveConfig, DirectiveObjectArguments } from './config.js'; +import type { DirectiveConfig, DirectiveObjectArguments, DirectiveSchemaTemplate } from './config.js'; import { Kind, valueFromASTUntyped } from 'graphql'; import { isConvertableRegexp } from './regexp.js'; @@ -9,13 +9,15 @@ export interface FormattedDirectiveConfig { } export interface FormattedDirectiveArguments { - [argument: string]: string[] | FormattedDirectiveObjectArguments | undefined + [argument: string]: DirectiveSchemaTemplate[] | FormattedDirectiveObjectArguments | undefined } export interface FormattedDirectiveObjectArguments { - [matched: string]: string[] | undefined + [matched: string]: DirectiveSchemaTemplate[] | undefined } +const DIRECTIVE_SCHEMA_KEY = '__directive'; + function isFormattedDirectiveObjectArguments(arg: FormattedDirectiveArguments[keyof FormattedDirectiveArguments]): arg is FormattedDirectiveObjectArguments { return arg !== undefined && !Array.isArray(arg) } @@ -47,12 +49,18 @@ function isFormattedDirectiveObjectArguments(arg: FormattedDirectiveArguments[ke export function formatDirectiveConfig(config: DirectiveConfig): FormattedDirectiveConfig { return Object.fromEntries( Object.entries(config).map(([directive, arg]) => { + if (Array.isArray(arg)) + return [directive, { [DIRECTIVE_SCHEMA_KEY]: arg }]; + + if (typeof arg !== 'object' || arg === null || typeof arg === 'function') + return [directive, { [DIRECTIVE_SCHEMA_KEY]: [arg] }]; + const formatted = Object.fromEntries( Object.entries(arg).map(([arg, val]) => { if (Array.isArray(val)) return [arg, val]; - if (typeof val === 'string') + if (typeof val !== 'object' || val === null || typeof val === 'function') return [arg, [val, '$1']]; return [arg, formatDirectiveObjectArguments(val)]; @@ -154,13 +162,16 @@ export function buildApiForValibot(config: FormattedDirectiveConfig, directives: .flat() } -function buildApiSchema(validationSchema: string[] | undefined, argValue: ConstValueNode): string { +function buildApiSchema(validationSchema: DirectiveSchemaTemplate[] | undefined, argValue?: ConstValueNode): string { if (!validationSchema) return ''; const schemaApi = validationSchema[0]; + if (typeof schemaApi !== 'string') + return ''; + const schemaApiArgs = validationSchema.slice(1).map((templateArg) => { - const gqlSchemaArgs = apiArgsFromConstValueNode(argValue); + const gqlSchemaArgs = argValue ? apiArgsFromConstValueNode(argValue) : []; return applyArgToApiSchemaTemplate(templateArg, gqlSchemaArgs); }); return `.${schemaApi}(${schemaApiArgs.join(', ')})`; @@ -171,6 +182,11 @@ function buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, arg } function _buildApiFromDirectiveArguments(config: FormattedDirectiveArguments, args: ReadonlyArray): string[] { + if (args.length === 0) { + const validationSchema = config[DIRECTIVE_SCHEMA_KEY]; + return [isFormattedDirectiveObjectArguments(validationSchema) ? '' : buildApiSchema(validationSchema)]; + } + return args .map((arg) => { const argName = arg.name.value; @@ -190,7 +206,10 @@ function buildApiFromDirectiveObjectArguments(config: FormattedDirectiveObjectAr return buildApiSchema(validationSchema, argValue); } -function applyArgToApiSchemaTemplate(template: string, apiArgs: any[]): string { +function applyArgToApiSchemaTemplate(template: DirectiveSchemaTemplate, apiArgs: any[]): string { + if (typeof template !== 'string') + return stringify(applyArgsToTemplateValue(template, apiArgs)); + const matches = template.matchAll(/\$(\d+)/g); for (const match of matches) { const placeholder = match[0]; // `$1` @@ -215,6 +234,9 @@ function stringify(arg: any, quoteString?: boolean): string { if (Array.isArray(arg)) return arg.map(v => stringify(v, true)).join(','); + if (typeof arg === 'function') + return arg.toString(); + if (typeof arg === 'string') { if (isConvertableRegexp(arg)) return arg; @@ -233,6 +255,33 @@ function stringify(arg: any, quoteString?: boolean): string { return JSON.stringify(arg); } +function applyArgsToTemplateValue(template: DirectiveSchemaTemplate, apiArgs: any[]): any { + if (typeof template === 'string') { + if (template === '') + return template; + + let value = template; + for (const match of template.matchAll(/\$(\d+)/g)) { + const placeholder = match[0]; + const idx = Number.parseInt(match[1], 10) - 1; + const apiArg = apiArgs[idx]; + value = value.replace(placeholder, apiArg === undefined ? '' : apiArg); + } + return value; + } + + if (Array.isArray(template)) + return template.map(item => applyArgsToTemplateValue(item, apiArgs)); + + if (template && typeof template === 'object') { + return Object.fromEntries( + Object.entries(template).map(([key, value]) => [key, applyArgsToTemplateValue(value, apiArgs)]), + ); + } + + return template; +} + function apiArgsFromConstValueNode(value: ConstValueNode): any[] { const val = valueFromASTUntyped(value); if (Array.isArray(val)) diff --git a/src/index.ts b/src/index.ts index 7a37065e..dbff7f6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import type { GraphQLSchema } from 'graphql'; import type { ValidationSchemaPluginConfig } from './config.js'; import type { SchemaVisitor } from './types.js'; import { transformSchemaAST } from '@graphql-codegen/schema-ast'; -import { buildSchema, printSchema, visit } from 'graphql'; +import { buildSchema, Kind, printSchema, visit } from 'graphql'; import { isGeneratedByIntrospection, topologicalSortAST } from './graphql.js'; import { MyZodSchemaVisitor } from './myzod/index.js'; @@ -14,19 +14,26 @@ import { ZodV4SchemaVisitor } from './zodv4/index.js'; export const plugin: PluginFunction = ( schema: GraphQLSchema, - _documents: Types.DocumentFile[], + documents: Types.DocumentFile[], config: ValidationSchemaPluginConfig, ): Types.ComplexPluginOutput => { const { schema: _schema, ast } = _transformSchemaAST(schema, config); const visitor = schemaVisitor(_schema, config); - const result = visit(ast, visitor); + const visitAst = config.onlyEnums + ? { ...ast, definitions: ast.definitions.filter(def => def.kind === Kind.ENUM_TYPE_DEFINITION) } + : ast; + + const result = visit(visitAst, visitor); const generated = result.definitions.filter(def => typeof def === 'string'); + const operationDefinitions = config.withOperationType === true && config.onlyEnums !== true + ? visitor.buildOperationDefinitions(documents) + : []; return { prepend: visitor.buildImports(), - content: [visitor.initialEmit(), ...generated].join('\n'), + content: [visitor.initialEmit(), ...generated, ...operationDefinitions].join('\n'), }; }; diff --git a/src/myzod/index.ts b/src/myzod/index.ts index bfbb706a..d84afce7 100644 --- a/src/myzod/index.ts +++ b/src/myzod/index.ts @@ -163,9 +163,11 @@ export class MyZodSchemaVisitor extends BaseSchemaVisitor { return { leave: (node: EnumTypeDefinitionNode) => { const visitor = this.createVisitor('both'); - const enumname = visitor.convertName(node.name.value); + const enumname = visitor.convertSchemaName(node.name.value, node.kind); const enumTypeName = visitor.prefixTypeNamespace(enumname); this.importTypes.push(enumname); + if (!this.config.enumsAsTypes) + this.importValueTypes.push(enumname); // z.enum are basically myzod.literals // hoist enum declarations this.enumDeclarations.push( @@ -300,7 +302,10 @@ function generateFieldTypeMyZodSchema(config: ValidationSchemaPluginConfig, visi if (config.namingConvention?.enumValues) value = convertNameParts(defaultValue.value, resolveExternalModuleAndFn(config.namingConvention?.enumValues), config?.namingConvention?.transformUnderscore); - appliedDirectivesGen = `${appliedDirectivesGen}.default(${visitor.convertName(type.name.value)}.${value})`; + const enumTypeName = visitor.prefixTypeNamespace( + visitor.convertSchemaName(type.name.value, visitor.getType(type.name.value)?.astNode?.kind), + ); + appliedDirectivesGen = `${appliedDirectivesGen}.default(${enumTypeName}.${value})`; } else { appliedDirectivesGen = `${appliedDirectivesGen}.default("${escapeGraphQLCharacters(defaultValue.value)}")`; diff --git a/src/schema_visitor.ts b/src/schema_visitor.ts index ca2daa66..669ce6bb 100644 --- a/src/schema_visitor.ts +++ b/src/schema_visitor.ts @@ -1,3 +1,6 @@ +import type { + Types, +} from '@graphql-codegen/plugin-helpers'; import type { FieldDefinitionNode, GraphQLSchema, @@ -12,6 +15,7 @@ import { Visitor } from './visitor.js'; export abstract class BaseSchemaVisitor implements SchemaVisitor { protected importTypes: string[] = []; + protected importValueTypes: string[] = []; protected enumDeclarations: string[] = []; constructor( @@ -24,21 +28,38 @@ export abstract class BaseSchemaVisitor implements SchemaVisitor { buildImports(): string[] { if (this.config.importFrom && this.importTypes.length > 0) { const namedImportPrefix = this.config.useTypeImports ? 'type ' : ''; + const importValueTypes = [...new Set(this.importValueTypes)]; + const importTypes = [...new Set(this.importTypes)] + .filter(type => this.config.useTypeImports !== true || !importValueTypes.includes(type)); const importCore = this.config.schemaNamespacedImportName ? `* as ${this.config.schemaNamespacedImportName}` - : `${namedImportPrefix}{ ${this.importTypes.join(', ')} }`; + : `${namedImportPrefix}{ ${importTypes.join(', ')} }`; + + const imports = [this.importValidationSchema()]; + + if (this.config.schemaNamespacedImportName) { + imports.push(`import ${importCore} from '${this.config.importFrom}'`); + return imports; + } + + if (this.config.useTypeImports === true && importValueTypes.length > 0) + imports.push(`import { ${importValueTypes.join(', ')} } from '${this.config.importFrom}'`); - return [ - this.importValidationSchema(), - `import ${importCore} from '${this.config.importFrom}'`, - ]; + if (importTypes.length > 0) + imports.push(`import ${importCore} from '${this.config.importFrom}'`); + + return imports; } return [this.importValidationSchema()]; } abstract initialEmit(): string; + buildOperationDefinitions(_documents: Types.DocumentFile[]): string[] { + return []; + } + createVisitor(scalarDirection: 'input' | 'output' | 'both'): Visitor { return new Visitor(scalarDirection, this.schema, this.config); } diff --git a/src/types.ts b/src/types.ts index 6c73e870..b940c984 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import type { Types } from '@graphql-codegen/plugin-helpers'; import type { ASTNode, ASTVisitFn } from 'graphql'; export type NewVisitor = Partial<{ @@ -9,4 +10,5 @@ export type NewVisitor = Partial<{ export interface SchemaVisitor extends NewVisitor { buildImports: () => string[] initialEmit: () => string + buildOperationDefinitions: (documents: Types.DocumentFile[]) => string[] } diff --git a/src/valibot/index.ts b/src/valibot/index.ts index 722c7ecd..c52cdd65 100644 --- a/src/valibot/index.ts +++ b/src/valibot/index.ts @@ -124,9 +124,11 @@ export class ValibotSchemaVisitor extends BaseSchemaVisitor { return { leave: (node: EnumTypeDefinitionNode) => { const visitor = this.createVisitor('both'); - const enumname = visitor.convertName(node.name.value); + const enumname = visitor.convertSchemaName(node.name.value, node.kind); const enumTypeName = visitor.prefixTypeNamespace(enumname); this.importTypes.push(enumname); + if (!this.config.enumsAsTypes) + this.importValueTypes.push(enumname); // hoist enum declarations this.enumDeclarations.push( @@ -205,13 +207,13 @@ export class ValibotSchemaVisitor extends BaseSchemaVisitor { 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(visitor, field.type, gen)}`, indentCount); + return indent(`${field.name.value}: ${gen}`, indentCount); } function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, field: InputValueDefinitionNode | FieldDefinitionNode, type: TypeNode, parentType?: TypeNode): string { if (isListType(type)) { const gen = generateFieldTypeValibotSchema(config, visitor, field, type.type, type); - const arrayGen = `v.array(${maybeLazy(visitor, type.type, gen)})`; + const arrayGen = `v.array(${gen})`; if (!isNonNullType(parentType)) return `v.nullish(${arrayGen})`; @@ -219,24 +221,25 @@ function generateFieldTypeValibotSchema(config: ValidationSchemaPluginConfig, vi } if (isNonNullType(type)) { const gen = generateFieldTypeValibotSchema(config, visitor, field, type.type, type); - return maybeLazy(visitor, type.type, gen); + return gen; } if (isNamedType(type)) { const gen = generateNameNodeValibotSchema(config, visitor, type.name); if (isListType(parentType)) - return `v.nullable(${gen})`; + return `v.nullable(${maybeLazy(visitor, type, gen)})`; const actions = actionsFromDirectives(config, field); + const schema = maybeLazy(visitor, type, pipeSchemaAndActions(gen, actions)); if (isNonNullType(parentType)) { if (visitor.shouldEmitAsNotAllowEmptyString(type.name.value)) { actions.push('v.minLength(1)'); } - return pipeSchemaAndActions(gen, actions); + return maybeLazy(visitor, type, pipeSchemaAndActions(gen, actions)); } - return `v.nullish(${pipeSchemaAndActions(gen, actions)})`; + return `v.nullish(${schema})`; } console.warn('unhandled type:', type); return ''; diff --git a/src/visitor.ts b/src/visitor.ts index 4d649090..70bcfbfc 100644 --- a/src/visitor.ts +++ b/src/visitor.ts @@ -45,10 +45,18 @@ export class Visitor extends TsVisitor { return { targetKind: astNode.kind, - convertName: () => this.convertName(astNode.name.value), + convertName: () => this.convertSchemaName(astNode.name.value, astNode.kind), }; } + public convertSchemaName(name: string, kind?: string): string { + return this.convertName(name, { + useTypesPrefix: kind === 'EnumTypeDefinition' && this.pluginConfig.enumPrefix === false + ? false + : undefined, + }); + } + public getScalarType(scalarName: string): string | null { if (this.scalarDirection === 'both') return null; diff --git a/src/yup/index.ts b/src/yup/index.ts index 0dd808b1..70302adc 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -175,9 +175,11 @@ export class YupSchemaVisitor extends BaseSchemaVisitor { return { leave: (node: EnumTypeDefinitionNode) => { const visitor = this.createVisitor('both'); - const enumname = visitor.convertName(node.name.value); + const enumname = visitor.convertSchemaName(node.name.value, node.kind); const enumTypeName = visitor.prefixTypeNamespace(enumname); this.importTypes.push(enumname); + if (!this.config.enumsAsTypes) + this.importValueTypes.push(enumname); // hoise enum declarations if (this.config.enumsAsTypes) { @@ -307,7 +309,10 @@ function shapeFields(fields: readonly (FieldDefinitionNode | InputValueDefinitio : isNamedType(field.type) ? field.type.name.value : field.name.value; - fieldSchema = `${fieldSchema}.default(${visitor.convertName(enumName)}.${value})`; + const enumTypeName = visitor.prefixTypeNamespace( + visitor.convertSchemaName(enumName, visitor.getType(enumName)?.astNode?.kind), + ); + fieldSchema = `${fieldSchema}.default(${enumTypeName}.${value})`; } else { fieldSchema = `${fieldSchema}.default("${escapeGraphQLCharacters(defaultValue.value)}")`; diff --git a/src/zod/index.ts b/src/zod/index.ts index 9161369b..426b3bc3 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -1,8 +1,11 @@ +import type { Types } from '@graphql-codegen/plugin-helpers'; import type { + ConstValueNode, EnumTypeDefinitionNode, FieldDefinitionNode, GraphQLSchema, InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, InputValueDefinitionNode, InterfaceTypeDefinitionNode, NameNode, @@ -17,8 +20,10 @@ import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers'; import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { isEnumType, + isInputObjectType, isScalarType, Kind, + valueFromASTUntyped, } from 'graphql'; import { buildApi, formatDirectiveConfig } from '../directive.js'; import { @@ -30,6 +35,7 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { buildZodOperationSchemas } from './operation.js'; const anySchema = `definedNonNullAnySchema`; @@ -50,7 +56,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { new DeclarationBlock({}) .asKind('type') .withName('Properties') - .withContent(['Required<{', ' [K in keyof T]: z.ZodType;', '}>'].join('\n')) + .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. @@ -73,12 +79,22 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { ); } + buildOperationDefinitions(documents: Types.DocumentFile[]): string[] { + const visitor = this.createVisitor('output'); + const result = buildZodOperationSchemas(this.schema, this.config, documents, visitor); + this.importTypes.push(...result.typeNames); + return result.blocks; + } + get InputObjectTypeDefinition() { return { leave: (node: InputObjectTypeDefinitionNode) => { const visitor = this.createVisitor('input'); const name = visitor.convertName(node.name.value); this.importTypes.push(name); + if (isOneOfInputObject(node)) + return this.buildOneOfInputFields(node.fields ?? [], visitor, name); + return this.buildInputFields(node.fields ?? [], visitor, name); }, }; @@ -97,7 +113,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2, schemaDepthVariable(this.config))).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -106,7 +122,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent([`z.object({`, shape, '})'].join('\n')) + .withContent(buildObjectExpression(this.config, shape)) .string + appendArguments ); @@ -116,8 +132,8 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { new DeclarationBlock({}) .export() .asKind('function') - .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) + .withBlock(buildObjectReturn(this.config, shape)) .string + appendArguments ); } @@ -138,7 +154,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { const appendArguments = argumentBlocks ? `\n${argumentBlocks}` : ''; // Building schema for fields. - const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const shape = node.fields?.map(field => generateFieldZodSchema(this.config, visitor, field, 2, schemaDepthVariable(this.config))).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -147,14 +163,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent( - [ - `z.object({`, - indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), - shape, - '})', - ].join('\n'), - ) + .withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) .string + appendArguments ); @@ -164,15 +173,8 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { 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'), - ) + .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) + .withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) .string + appendArguments ); } @@ -184,9 +186,12 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { return { leave: (node: EnumTypeDefinitionNode) => { const visitor = this.createVisitor('both'); - const enumname = visitor.convertName(node.name.value); + const enumname = visitor.convertSchemaName(node.name.value, node.kind); const enumTypeName = visitor.prefixTypeNamespace(enumname); this.importTypes.push(enumname); + if (!this.config.enumsAsTypes) + this.importValueTypes.push(enumname); + const enumValues = node.values?.map(enumOption => enumOption.name.value) ?? []; // hoist enum declarations this.enumDeclarations.push( @@ -194,13 +199,13 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { ? new DeclarationBlock({}) .export() .asKind('const') - .withName(`${enumname}Schema`) - .withContent(`z.enum([${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')}])`) + .withName(`${enumname}Schema: z.ZodType<${unionLiterals(enumValues)}>`) + .withContent(`z.enum([${enumValues.map(enumOption => `'${enumOption}'`).join(', ')}])`) .string : new DeclarationBlock({}) .export() .asKind('const') - .withName(`${enumname}Schema`) + .withName(`${enumname}Schema: z.ZodType<${enumTypeName}>`) .withContent(`z.nativeEnum(${enumTypeName})`) .string, ); @@ -215,6 +220,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { return; const visitor = this.createVisitor('output'); const unionName = visitor.convertName(node.name.value); + const depthVariable = schemaDepthVariable(this.config); const unionElements = node.types.map((t) => { const element = visitor.convertName(t.name.value); const typ = visitor.getType(t.name.value); @@ -226,7 +232,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { return `${element}Schema`; case 'function': default: - return `${element}Schema()`; + return depthVariable ? `${element}Schema(depth)` : `${element}Schema()`; } }).join(', '); const unionElementsCount = node.types.length ?? 0; @@ -241,7 +247,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { return new DeclarationBlock({}) .export() .asKind('function') - .withName(`${unionName}Schema()`) + .withName(`${unionName}Schema(${schemaDepthParameter(this.config)})`) .withBlock(indent(`return ${union}`)) .string; } @@ -256,6 +262,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { ) { const typeName = visitor.prefixTypeNamespace(name); const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const objectSchema = buildObjectExpression(this.config, shape); switch (this.config.validationSchemaExportType) { case 'const': @@ -263,7 +270,7 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent(['z.object({', shape, '})'].join('\n')) + .withContent(objectSchema) .string; case 'function': @@ -272,58 +279,85 @@ export class ZodSchemaVisitor extends BaseSchemaVisitor { .export() .asKind('function') .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withBlock(buildObjectReturn(this.config, shape)) + .string; + } + } + + private buildOneOfInputFields( + fields: readonly InputValueDefinitionNode[], + visitor: Visitor, + name: string, + ) { + const typeName = visitor.prefixTypeNamespace(name); + const variants = fields.map((selectedField) => { + const shape = fields.map((field) => { + if (field === selectedField) { + const gen = generateFieldTypeZodSchema(this.config, visitor, field, field.type, undefined, true, true); + return indent(`${field.name.value}: ${withDescription(this.config, field, maybeLazy(visitor, field.type, gen))}`, 2); + } + + return indent(`${field.name.value}: z.never().optional()`, 2); + }).join(',\n'); + + return buildObjectExpression(this.config, shape); + }); + const schema = variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0]; + + switch (this.config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodType<${typeName}>`) + .withContent(schema) + .string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodType<${typeName}>`) + .withBlock(indent(`return ${schema}`)) .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(visitor, field.type, gen)}`, indentCount); +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): string { +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); - if (!isNonNullType(parentType)) { - const arrayGen = `z.array(${maybeLazy(visitor, type.type, gen)})`; - const maybeLazyGen = applyDirectives(config, field, arrayGen); - return `${maybeLazyGen}.nullish()`; + 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 `z.array(${maybeLazy(visitor, type.type, gen)})`; + return maybeDefaultGen; } if (isNonNullType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, 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); + const gen = generateNameNodeZodSchema(config, visitor, type.name, depthVariable); 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)}")`; - } - } - } + 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)) @@ -334,12 +368,148 @@ function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visito if (isListType(parentType)) return `${appliedDirectivesGen}.nullable()`; - return `${appliedDirectivesGen}.nullish()`; + 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); @@ -348,7 +518,7 @@ function applyDirectives(config: ValidationSchemaPluginConfig, field: InputValue return gen; } -function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode): string { +function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode, depthVariable?: string): string { const converter = visitor.getNameNodeConverter(node); switch (converter?.targetKind) { @@ -362,6 +532,16 @@ function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor 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': @@ -407,3 +587,10 @@ function zod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scal 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/operation.ts b/src/zod/operation.ts new file mode 100644 index 00000000..c48d77b4 --- /dev/null +++ b/src/zod/operation.ts @@ -0,0 +1,359 @@ +import type { Types } from '@graphql-codegen/plugin-helpers'; +import type { + FieldNode, + FragmentDefinitionNode, + GraphQLCompositeType, + GraphQLObjectType, + GraphQLOutputType, + GraphQLSchema, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, +} from 'graphql'; +import type { ValidationSchemaPluginConfig } from '../config.js'; +import type { Visitor } from '../visitor.js'; +import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; +import { + getNamedType, + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLString, + isEnumType, + isInterfaceType, + isListType, + isNonNullType, + isObjectType, + isScalarType, + isUnionType, + Kind, +} from 'graphql'; + +interface OperationBuildResult { + blocks: string[] + typeNames: string[] +} + +interface OperationContext { + schema: GraphQLSchema + config: ValidationSchemaPluginConfig + visitor: Visitor + fragments: Map +} + +interface CollectedField { + schema: string + optional: boolean +} + +export function buildZodOperationSchemas( + schema: GraphQLSchema, + config: ValidationSchemaPluginConfig, + documents: Types.DocumentFile[], + visitor: Visitor, +): OperationBuildResult { + const fragments = new Map(); + const operations: OperationDefinitionNode[] = []; + + for (const documentFile of documents) { + for (const definition of documentFile.document?.definitions ?? []) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) + fragments.set(definition.name.value, definition); + else if (definition.kind === Kind.OPERATION_DEFINITION && definition.name) + operations.push(definition); + } + } + + const ctx: OperationContext = { schema, config, visitor, fragments }; + const blocks: string[] = []; + const typeNames: string[] = []; + + for (const operation of operations) { + const rootType = operationRootType(schema, operation); + if (!rootType) + continue; + + const typeName = operationResultTypeName(visitor, operation); + typeNames.push(typeName); + const schemaExpression = buildSelectionSetSchema(ctx, rootType, operation.selectionSet); + + blocks.push(operationDeclaration(config, typeName, visitor.prefixTypeNamespace(typeName), schemaExpression)); + } + + return { blocks, typeNames }; +} + +function operationRootType(schema: GraphQLSchema, operation: OperationDefinitionNode): GraphQLCompositeType | undefined { + switch (operation.operation) { + case 'query': + return schema.getQueryType() ?? undefined; + case 'mutation': + return schema.getMutationType() ?? undefined; + case 'subscription': + return schema.getSubscriptionType() ?? undefined; + } +} + +function operationResultTypeName(visitor: Visitor, operation: OperationDefinitionNode): string { + const operationName = operation.name?.value ?? ''; + return visitor.convertName(`${operationName}${capitalize(operation.operation)}`); +} + +function operationDeclaration( + config: ValidationSchemaPluginConfig, + typeName: string, + typeReference: string, + schemaExpression: string, +): string { + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${typeName}Schema: z.ZodType<${typeReference}>`) + .withContent(schemaExpression) + .string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${typeName}Schema(): z.ZodType<${typeReference}>`) + .withBlock(indent(`return ${schemaExpression}`)) + .string; + } +} + +function buildSelectionSetSchema( + ctx: OperationContext, + parentType: GraphQLCompositeType, + selectionSet: SelectionSetNode, +): string { + if (isUnionType(parentType) || isInterfaceType(parentType)) { + const variants = ctx.schema + .getPossibleTypes(parentType) + .map(type => buildObjectSelectionSetSchema(ctx, parentType, type, selectionSet, false)); + + return variants.length > 1 + ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` + : variants[0] ?? 'definedNonNullAnySchema'; + } + + return buildObjectSelectionSetSchema(ctx, parentType, parentType, selectionSet, false); +} + +function buildObjectSelectionSetSchema( + ctx: OperationContext, + parentType: GraphQLCompositeType, + runtimeType: GraphQLObjectType, + selectionSet: SelectionSetNode, + optional: boolean, +): string { + const fields = new Map(); + + if (ctx.config.nonOptionalTypename === true) + setField(fields, '__typename', typenameSchema(runtimeType), false); + + for (const selection of selectionSet.selections) + collectSelection(ctx, parentType, runtimeType, selection, fields, optional); + + return buildObjectExpression( + ctx.config, + [...fields.entries()] + .map(([responseName, field]) => indent(`${responseName}: ${field.optional ? `${field.schema}.optional()` : field.schema}`, 2)) + .join(',\n'), + ); +} + +function collectSelection( + ctx: OperationContext, + parentType: GraphQLCompositeType, + runtimeType: GraphQLObjectType, + selection: SelectionNode, + fields: Map, + inheritedOptional: boolean, +): void { + const directiveState = executableDirectiveState(selection); + if (directiveState === 'omit') + return; + + const optional = inheritedOptional || directiveState === 'optional'; + + switch (selection.kind) { + case Kind.FIELD: + collectField(ctx, parentType, runtimeType, selection, fields, optional); + return; + case Kind.FRAGMENT_SPREAD: { + const fragment = ctx.fragments.get(selection.name.value); + const fragmentType = fragment ? fragmentTypeCondition(ctx, fragment) : undefined; + if (fragment && fragmentType && typesOverlap(ctx, fragmentType, runtimeType)) + collectSelectionSetForType(ctx, fragmentType, runtimeType, fragment.selectionSet, fields, optional); + return; + } + case Kind.INLINE_FRAGMENT: { + const fragmentType = selection.typeCondition ? typeCondition(ctx, selection.typeCondition.name.value) : parentType; + if (fragmentType && typesOverlap(ctx, fragmentType, runtimeType)) + collectSelectionSetForType(ctx, fragmentType, runtimeType, selection.selectionSet, fields, optional); + } + } +} + +function collectSelectionSetForType( + ctx: OperationContext, + parentType: GraphQLCompositeType, + runtimeType: GraphQLObjectType, + selectionSet: SelectionSetNode, + fields: Map, + optional: boolean, +): void { + for (const selection of selectionSet.selections) + collectSelection(ctx, parentType, runtimeType, selection, fields, optional); +} + +function collectField( + ctx: OperationContext, + parentType: GraphQLCompositeType, + runtimeType: GraphQLObjectType, + field: FieldNode, + fields: Map, + optional: boolean, +): void { + const responseName = field.alias?.value ?? field.name.value; + if (field.name.value === '__typename') { + setField(fields, responseName, typenameSchema(runtimeType), optional); + return; + } + + if (!isObjectType(parentType) && !isInterfaceType(parentType)) + return; + + const fieldDefinition = parentType.getFields()[field.name.value]; + if (!fieldDefinition) + return; + + setField(fields, responseName, buildOutputTypeSchema(ctx, fieldDefinition.type, field.selectionSet), optional); +} + +function buildOutputTypeSchema( + ctx: OperationContext, + type: GraphQLOutputType, + selectionSet?: SelectionSetNode, +): string { + if (isNonNullType(type)) + return buildNonNullOutputTypeSchema(ctx, type.ofType, selectionSet); + + return `${buildNonNullOutputTypeSchema(ctx, type, selectionSet)}.nullable()`; +} + +function buildNonNullOutputTypeSchema( + ctx: OperationContext, + type: GraphQLOutputType, + selectionSet?: SelectionSetNode, +): string { + if (isListType(type)) + return `z.array(${buildOutputTypeSchema(ctx, type.ofType, selectionSet)})`; + + const namedType = getNamedType(type); + if (isScalarType(namedType)) + return scalarSchema(ctx, namedType.name); + + if (isEnumType(namedType)) { + const converter = ctx.visitor.getNameNodeConverter({ kind: Kind.NAME, value: namedType.name }); + return `${converter?.convertName() ?? ctx.visitor.convertSchemaName(namedType.name, namedType.astNode?.kind)}Schema`; + } + + if (selectionSet && (isObjectType(namedType) || isInterfaceType(namedType) || isUnionType(namedType))) + return buildSelectionSetSchema(ctx, namedType, selectionSet); + + return 'definedNonNullAnySchema'; +} + +function scalarSchema(ctx: OperationContext, scalarName: string): string { + if (ctx.config.scalarSchemas?.[scalarName]) + return ctx.config.scalarSchemas[scalarName]; + + switch (scalarName) { + case GraphQLString.name: + case GraphQLID.name: + return 'z.string()'; + case GraphQLInt.name: + case GraphQLFloat.name: + return 'z.number()'; + case GraphQLBoolean.name: + return 'z.boolean()'; + } + + if (ctx.config.defaultScalarTypeSchema) + return ctx.config.defaultScalarTypeSchema; + + console.warn('unhandled scalar name:', scalarName); + return 'definedNonNullAnySchema'; +} + +function fragmentTypeCondition(ctx: OperationContext, fragment: FragmentDefinitionNode): GraphQLCompositeType | undefined { + return typeCondition(ctx, fragment.typeCondition.name.value); +} + +function typeCondition(ctx: OperationContext, typeName: string): GraphQLCompositeType | undefined { + const type = ctx.schema.getType(typeName); + return type && (isObjectType(type) || isInterfaceType(type) || isUnionType(type)) ? type : undefined; +} + +function typesOverlap(ctx: OperationContext, conditionalType: GraphQLCompositeType, runtimeType: GraphQLObjectType): boolean { + if (isObjectType(conditionalType)) + return conditionalType.name === runtimeType.name; + + return ctx.schema.isSubType(conditionalType, runtimeType); +} + +function setField(fields: Map, responseName: string, schema: string, optional: boolean): void { + const existing = fields.get(responseName); + if (existing && !existing.optional) + return; + + fields.set(responseName, { schema, optional }); +} + +function typenameSchema(parentType: GraphQLObjectType): string { + return `z.literal('${parentType.name}')`; +} + +function executableDirectiveState(selection: SelectionNode): 'omit' | 'optional' | 'required' { + let optional = false; + + for (const directive of selection.directives ?? []) { + if (directive.name.value !== 'skip' && directive.name.value !== 'include') + continue; + + const condition = directive.arguments?.find(argument => argument.name.value === 'if')?.value; + if (!condition) + continue; + + if (condition.kind !== Kind.BOOLEAN) { + optional = true; + continue; + } + + if (directive.name.value === 'skip' && condition.value) + return 'omit'; + + if (directive.name.value === 'include' && !condition.value) + return 'omit'; + } + + return optional ? 'optional' : 'required'; +} + +function buildObjectExpression(config: ValidationSchemaPluginConfig, shape: string): string { + return ['z.object({', shape, `})${strictObjectSuffix(config)}`].join('\n'); +} + +function strictObjectSuffix(config: ValidationSchemaPluginConfig): string { + return config.strictObjectSchemas === true ? '.strict()' : ''; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} diff --git a/src/zodv4/index.ts b/src/zodv4/index.ts index 38e1aabe..300be9a6 100644 --- a/src/zodv4/index.ts +++ b/src/zodv4/index.ts @@ -1,8 +1,11 @@ +import type { Types } from '@graphql-codegen/plugin-helpers'; import type { + ConstValueNode, EnumTypeDefinitionNode, FieldDefinitionNode, GraphQLSchema, InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, InputValueDefinitionNode, InterfaceTypeDefinitionNode, NameNode, @@ -17,8 +20,10 @@ import { resolveExternalModuleAndFn } from '@graphql-codegen/plugin-helpers'; import { convertNameParts, DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { isEnumType, + isInputObjectType, isScalarType, Kind, + valueFromASTUntyped, } from 'graphql'; import { buildApi, formatDirectiveConfig } from '../directive.js'; import { @@ -30,6 +35,7 @@ import { ObjectTypeDefinitionBuilder, } from '../graphql.js'; import { BaseSchemaVisitor } from '../schema_visitor.js'; +import { buildZodOperationSchemas } from '../zod/operation.js'; const anySchema = `definedNonNullAnySchema`; @@ -49,7 +55,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { new DeclarationBlock({}) .asKind('type') .withName('Properties') - .withContent(['Required<{', ' [K in keyof T]: z.ZodType;', '}>'].join('\n')) + .withContent(['{', ' [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. @@ -72,12 +78,22 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { ); } + buildOperationDefinitions(documents: Types.DocumentFile[]): string[] { + const visitor = this.createVisitor('output'); + const result = buildZodOperationSchemas(this.schema, this.config, documents, visitor); + this.importTypes.push(...result.typeNames); + return result.blocks; + } + get InputObjectTypeDefinition() { return { leave: (node: InputObjectTypeDefinitionNode) => { const visitor = this.createVisitor('input'); const name = visitor.convertName(node.name.value); this.importTypes.push(name); + if (isOneOfInputObject(node)) + return this.buildOneOfInputFields(node.fields ?? [], visitor, name); + return this.buildInputFields(node.fields ?? [], visitor, name); }, }; @@ -96,7 +112,7 @@ export class ZodV4SchemaVisitor 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, schemaDepthVariable(this.config))).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -105,7 +121,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent([`z.object({`, shape, '})'].join('\n')) + .withContent(buildObjectExpression(this.config, shape)) .string + appendArguments ); @@ -115,8 +131,8 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { new DeclarationBlock({}) .export() .asKind('function') - .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) + .withBlock(buildObjectReturn(this.config, shape)) .string + appendArguments ); } @@ -137,7 +153,7 @@ export class ZodV4SchemaVisitor 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, schemaDepthVariable(this.config))).join(',\n'); switch (this.config.validationSchemaExportType) { case 'const': @@ -146,14 +162,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { .export() .asKind('const') .withName(`${name}Schema: z.ZodObject>`) - .withContent( - [ - `z.object({`, - indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), - shape, - '})', - ].join('\n'), - ) + .withContent(buildObjectExpression(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) .string + appendArguments ); @@ -163,15 +172,8 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { 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'), - ) + .withName(`${name}Schema(${schemaDepthParameter(this.config)}): z.ZodObject>`) + .withBlock(buildObjectReturn(this.config, [indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape].join('\n'))) .string + appendArguments ); } @@ -183,19 +185,26 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { return { leave: (node: EnumTypeDefinitionNode) => { const visitor = this.createVisitor('both'); - const enumname = visitor.convertName(node.name.value); + const enumname = visitor.convertSchemaName(node.name.value, node.kind); const enumTypeName = visitor.prefixTypeNamespace(enumname); this.importTypes.push(enumname); + if (!this.config.enumsAsTypes) + this.importValueTypes.push(enumname); + const enumValues = node.values?.map(enumOption => enumOption.name.value) ?? []; // hoist enum declarations this.enumDeclarations.push( new DeclarationBlock({}) .export() .asKind('const') - .withName(`${enumname}Schema`) + .withName( + this.config.enumsAsTypes + ? `${enumname}Schema: z.ZodType<${unionLiterals(enumValues)}, ${unionLiterals(enumValues)}>` + : `${enumname}Schema: z.ZodType<${enumTypeName}, ${enumTypeName}>`, + ) .withContent( this.config.enumsAsTypes - ? `z.enum([${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')}])` + ? `z.enum([${enumValues.map(enumOption => `'${enumOption}'`).join(', ')}])` : `z.enum(${enumTypeName})`, ) .string, @@ -211,6 +220,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { return; const visitor = this.createVisitor('output'); const unionName = visitor.convertName(node.name.value); + const depthVariable = schemaDepthVariable(this.config); const unionElements = node.types.map((t) => { const element = visitor.convertName(t.name.value); const typ = visitor.getType(t.name.value); @@ -222,7 +232,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { return `${element}Schema`; case 'function': default: - return `${element}Schema()`; + return depthVariable ? `${element}Schema(depth)` : `${element}Schema()`; } }).join(', '); const unionElementsCount = node.types.length ?? 0; @@ -237,7 +247,7 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { return new DeclarationBlock({}) .export() .asKind('function') - .withName(`${unionName}Schema()`) + .withName(`${unionName}Schema(${schemaDepthParameter(this.config)})`) .withBlock(indent(`return ${union}`)) .string; } @@ -252,14 +262,16 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { ) { const typeName = visitor.prefixTypeNamespace(name); const shape = fields.map(field => generateFieldZodSchema(this.config, visitor, field, 2)).join(',\n'); + const objectSchema = buildObjectExpression(this.config, shape); + const schemaType = hasDefaultValue(fields) ? `z.ZodType<${typeName}>` : `z.ZodObject>`; switch (this.config.validationSchemaExportType) { case 'const': return new DeclarationBlock({}) .export() .asKind('const') - .withName(`${name}Schema: z.ZodObject>`) - .withContent(['z.object({', shape, '})'].join('\n')) + .withName(`${name}Schema: ${schemaType}`) + .withContent(objectSchema) .string; case 'function': @@ -267,59 +279,86 @@ export class ZodV4SchemaVisitor extends BaseSchemaVisitor { return new DeclarationBlock({}) .export() .asKind('function') - .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')) + .withName(`${name}Schema(): ${schemaType}`) + .withBlock(buildObjectReturn(this.config, shape)) + .string; + } + } + + private buildOneOfInputFields( + fields: readonly InputValueDefinitionNode[], + visitor: Visitor, + name: string, + ) { + const typeName = visitor.prefixTypeNamespace(name); + const variants = fields.map((selectedField) => { + const shape = fields.map((field) => { + if (field === selectedField) { + const gen = generateFieldTypeZodSchema(this.config, visitor, field, field.type, undefined, true, true); + return indent(`${field.name.value}: ${withDescription(this.config, field, maybeLazy(visitor, field.type, gen))}`, 2); + } + + return indent(`${field.name.value}: z.never().optional()`, 2); + }).join(',\n'); + + return buildObjectExpression(this.config, shape); + }); + const schema = variants.length > 1 ? `z.union([\n${variants.map(variant => indent(variant, 2)).join(',\n')}\n])` : variants[0]; + + switch (this.config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodType<${typeName}>`) + .withContent(schema) + .string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodType<${typeName}>`) + .withBlock(indent(`return ${schema}`)) .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(visitor, field.type, gen)}`, indentCount); +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): string { +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); - if (!isNonNullType(parentType)) { - const arrayGen = `z.array(${maybeLazy(visitor, type.type, gen)})`; - const maybeLazyGen = applyDirectives(config, field, arrayGen); - return `${maybeLazyGen}.nullish()`; + 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 `z.array(${maybeLazy(visitor, type.type, gen)})`; + return maybeDefaultGen; } if (isNonNullType(type)) { - const gen = generateFieldTypeZodSchema(config, visitor, field, type.type, 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); + const gen = generateNameNodeZodSchema(config, visitor, type.name, depthVariable); 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)}")`; - } - } - } + 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)) @@ -330,12 +369,152 @@ function generateFieldTypeZodSchema(config: ValidationSchemaPluginConfig, visito if (isListType(parentType)) return `${appliedDirectivesGen}.nullable()`; - return `${appliedDirectivesGen}.nullish()`; + 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); @@ -344,7 +523,7 @@ function applyDirectives(config: ValidationSchemaPluginConfig, field: InputValue return gen; } -function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode): string { +function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode, depthVariable?: string): string { const converter = visitor.getNameNodeConverter(node); switch (converter?.targetKind) { @@ -358,6 +537,16 @@ function generateNameNodeZodSchema(config: ValidationSchemaPluginConfig, visitor 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': @@ -403,3 +592,10 @@ function zod4Scalar(config: ValidationSchemaPluginConfig, visitor: Visitor, scal 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/directive.spec.ts b/tests/directive.spec.ts index b3aa0c02..13b13ea5 100644 --- a/tests/directive.spec.ts +++ b/tests/directive.spec.ts @@ -1,5 +1,5 @@ import type { ConstArgumentNode, ConstDirectiveNode, ConstValueNode, NameNode } from 'graphql'; -import type { DirectiveConfig, DirectiveObjectArguments } from '../src/config'; +import type { DirectiveConfig, DirectiveObjectArguments, DirectiveSchemaTemplate } from '../src/config'; import type { FormattedDirectiveArguments, FormattedDirectiveConfig, @@ -141,6 +141,17 @@ describe('format directive config', () => { }, }, }, + { + name: 'directive without arguments', + arg: { + Positive: 'positive', + }, + want: { + Positive: { + __directive: ['positive'], + }, + }, + }, ]; for (const tc of cases) { it(tc.name, () => { @@ -154,7 +165,7 @@ describe('format directive config', () => { const cases: { name: string args: { - template: string + template: DirectiveSchemaTemplate apiArgs: any[] } want: string @@ -239,6 +250,22 @@ describe('format directive config', () => { }, want: `{"hello":"world"}`, }, + { + name: 'function', + args: { + template: (items: unknown[]) => new Set(items).size === items.length, + apiArgs: [], + }, + want: `(items) => new Set(items).size === items.length`, + }, + { + name: 'object template', + args: { + template: { required_error: '$1' }, + apiArgs: ['The field is required.'], + }, + want: `{"required_error":"The field is required."}`, + }, { name: 'undefined', args: { @@ -528,6 +555,40 @@ describe('format directive config', () => { }, want: `.required("message")`, }, + { + name: 'directive without arguments', + args: { + config: { + __directive: ['positive'], + }, + args: [], + }, + want: `.positive()`, + }, + { + name: 'function argument', + args: { + config: { + unique: ['refine', (items: unknown[]) => new Set(items).size === items.length], + }, + args: buildConstArgumentNodes({ + unique: `true`, + }), + }, + want: `.refine((items) => new Set(items).size === items.length)`, + }, + { + name: 'object argument template', + args: { + config: { + errorMessage: ['string', { required_error: '$1' }], + }, + args: buildConstArgumentNodes({ + errorMessage: `"The field is required."`, + }), + }, + want: `.string({"required_error":"The field is required."})`, + }, ]; for (const tc of cases) { it(tc.name, () => { diff --git a/tests/myzod.spec.ts b/tests/myzod.spec.ts index 8b51443a..b8e8f333 100644 --- a/tests/myzod.spec.ts +++ b/tests/myzod.spec.ts @@ -1718,4 +1718,60 @@ describe('myzod', () => { expect(result.content).toContain('ratio: myzod.number().default(0.5).optional().nullable()'); expect(result.content).toContain('isMember: myzod.boolean().default(true).optional().nullable()'); }); + + it('respects enumPrefix: false when typesPrefix is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + } + + input CreateUserInput { + role: UserRole! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + importFrom: './types', + useTypeImports: true, + typesPrefix: 'I', + enumPrefix: false, + }, + {}, + ); + + expect(result.prepend).toContain('import { UserRole } from \'./types\''); + expect(result.prepend).toContain('import type { ICreateUserInput } from \'./types\''); + expect(result.content).toContain('export const UserRoleSchema = myzod.enum(UserRole)'); + expect(result.content).toContain('export function ICreateUserInputSchema(): myzod.Type'); + expect(result.content).toContain('role: UserRoleSchema'); + }); + + it('qualifies enum defaults with namespace imports', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input PageInput { + pageType: PageType! = PUBLIC + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'myzod', + importFrom: './types', + schemaNamespacedImportName: 't', + useEnumTypeAsDefaultValue: true, + }, + {}, + ); + + expect(result.content).toContain('pageType: PageTypeSchema.default(t.PageType.Public)'); + }); }); diff --git a/tests/typescript-compile.ts b/tests/typescript-compile.ts new file mode 100644 index 00000000..0c75d6f4 --- /dev/null +++ b/tests/typescript-compile.ts @@ -0,0 +1,37 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import * as ts from 'typescript'; + +export function expectTypeScriptToCompile(source: string) { + const dir = mkdtempSync(path.join(process.cwd(), 'tests/.tmp-ts-')); + const fileName = path.join(dir, 'generated.ts'); + + try { + writeFileSync(fileName, source); + const program = ts.createProgram([fileName], { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + types: [], + }); + const diagnostics = ts.getPreEmitDiagnostics(program); + + expect( + diagnostics.map((diagnostic) => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + if (!diagnostic.file || diagnostic.start === undefined) + return message; + + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `${diagnostic.file.fileName}:${line + 1}:${character + 1} ${message}`; + }), + ).toStrictEqual([]); + } + finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/tests/valibot.spec.ts b/tests/valibot.spec.ts index 70f3af3e..762f6e47 100644 --- a/tests/valibot.spec.ts +++ b/tests/valibot.spec.ts @@ -181,8 +181,8 @@ describe('valibot', () => { export function NestedInputSchema(): v.GenericSchema { return v.object({ - child: v.lazy(() => v.nullish(NestedInputSchema())), - childrens: v.nullish(v.array(v.lazy(() => v.nullable(NestedInputSchema())))) + child: v.nullish(v.lazy(() => NestedInputSchema())), + childrens: v.nullish(v.array(v.nullable(v.lazy(() => NestedInputSchema())))) }) } " @@ -772,7 +772,7 @@ describe('valibot', () => { export function BookSchema(): v.GenericSchema { return v.object({ __typename: v.optional(v.literal('Book')), - author: v.lazy(() => v.nullish(AuthorSchema())), + author: v.nullish(v.lazy(() => AuthorSchema())), title: v.nullish(v.string()) }) } @@ -780,7 +780,7 @@ describe('valibot', () => { export function AuthorSchema(): v.GenericSchema { return v.object({ __typename: v.optional(v.literal('Author')), - books: v.nullish(v.array(v.lazy(() => v.nullable(BookSchema())))), + books: v.nullish(v.array(v.nullable(v.lazy(() => BookSchema())))), name: v.nullish(v.string()) }) } @@ -1019,7 +1019,7 @@ describe('valibot', () => { export function GeometrySchema(): v.GenericSchema { return v.object({ __typename: v.optional(v.literal('Geometry')), - shape: v.lazy(() => v.nullish(ShapeSchema())) + shape: v.nullish(v.lazy(() => ShapeSchema())) }) } " @@ -1209,14 +1209,14 @@ describe('valibot', () => { export function BookSchema(): v.GenericSchema { return v.object({ - author: v.lazy(() => v.nullish(AuthorSchema())), + author: v.nullish(v.lazy(() => AuthorSchema())), title: v.nullish(v.string()) }) } export function AuthorSchema(): v.GenericSchema { return v.object({ - books: v.nullish(v.array(v.lazy(() => v.nullable(BookSchema())))), + books: v.nullish(v.array(v.nullable(v.lazy(() => BookSchema())))), name: v.nullish(v.string()) }) } @@ -1296,6 +1296,49 @@ describe('valibot', () => { }); }) it.todo('properly generates custom directive values') + it('wraps lazy object references with nullish for nullable fields', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserInput { + kind: UserKind + } + + input UserKind { + name: String! + } + `); + + const result = await plugin(schema, [], { schema: 'valibot' }, {}); + expect(result.content).toContain('kind: v.nullish(v.lazy(() => UserKindSchema()))'); + }) + it('respects enumPrefix: false when typesPrefix is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + } + + input CreateUserInput { + role: UserRole! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'valibot', + importFrom: './types', + useTypeImports: true, + typesPrefix: 'I', + enumPrefix: false, + }, + {}, + ); + + expect(result.prepend).toContain('import { UserRole } from \'./types\''); + expect(result.prepend).toContain('import type { ICreateUserInput } from \'./types\''); + expect(result.content).toContain('export const UserRoleSchema = v.enum_(UserRole)'); + expect(result.content).toContain('export function ICreateUserInputSchema(): v.GenericSchema'); + expect(result.content).toContain('role: UserRoleSchema'); + }) it.todo('exports as const instead of func') it.todo('generate both input & type, export as const') it.todo('issue #394') diff --git a/tests/yup.spec.ts b/tests/yup.spec.ts index 37a6639e..c6483116 100644 --- a/tests/yup.spec.ts +++ b/tests/yup.spec.ts @@ -1,6 +1,7 @@ import { buildClientSchema, buildSchema, introspectionFromSchema } from 'graphql'; import { plugin } from '../src/index'; +import { expectTypeScriptToCompile } from './typescript-compile'; describe('yup', () => { it('defined', async () => { @@ -1717,4 +1718,104 @@ describe('yup', () => { expect(result.content).toContain('ratio: yup.number().defined().nullable().default(0.5).optional()'); expect(result.content).toContain('isMember: yup.boolean().defined().nullable().default(true).optional()'); }); + + it('respects enumPrefix: false when typesPrefix is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + } + + input CreateUserInput { + role: UserRole! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'yup', + importFrom: './types', + useTypeImports: true, + typesPrefix: 'I', + enumPrefix: false, + }, + {}, + ); + + expect(result.prepend).toContain('import { UserRole } from \'./types\''); + expect(result.prepend).toContain('import type { ICreateUserInput } from \'./types\''); + expect(result.content).toContain( + 'export const UserRoleSchema = yup.string().oneOf(Object.values(UserRole)).defined()', + ); + expect(result.content).toContain('export function ICreateUserInputSchema(): yup.ObjectSchema'); + expect(result.content).toContain('role: UserRoleSchema.nonNullable()'); + }); + + it('generates type-checkable yup v1 arrays of non-null input objects', async () => { + const schema = buildSchema(/* GraphQL */ ` + input QuestionAnswerInput { + answer: String + index: Int! + multichoiceAnswers: [String!]! + } + + input UpdateAssessmentInput { + answers: [QuestionAnswerInput!]! + domainIndex: Int! + encodedId: String! + groupIndex: Int! + previous: Boolean! + save: Boolean! + } + `); + + const result = await plugin(schema, [], { schema: 'yup' }, {}); + + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + export type Maybe = T | null; + export type QuestionAnswerInput = { + answer?: Maybe; + index: number; + multichoiceAnswers: string[]; + }; + export type UpdateAssessmentInput = { + answers: QuestionAnswerInput[]; + domainIndex: number; + encodedId: string; + groupIndex: number; + previous: boolean; + save: boolean; + }; + + ${result.content} + `); + }); + + it('qualifies enum defaults with namespace imports', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input PageInput { + pageType: PageType! = PUBLIC + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'yup', + importFrom: './types', + schemaNamespacedImportName: 't', + useEnumTypeAsDefaultValue: true, + }, + {}, + ); + + expect(result.content).toContain('pageType: PageTypeSchema.nonNullable().default(t.PageType.Public)'); + }); }); diff --git a/tests/zod.spec.ts b/tests/zod.spec.ts index e13d420c..413dd0a5 100644 --- a/tests/zod.spec.ts +++ b/tests/zod.spec.ts @@ -1,7 +1,8 @@ -import { buildClientSchema, buildSchema, introspectionFromSchema } from 'graphql'; +import { buildClientSchema, buildSchema, introspectionFromSchema, parse } from 'graphql'; import { dedent } from 'ts-dedent'; import { plugin } from '../src/index'; +import { expectTypeScriptToCompile } from './typescript-compile'; const initialEmitValue = dedent(` type Properties = Required<{ @@ -44,6 +45,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function PrimitiveInputSchema(): z.ZodObject> { return z.object({ a: z.string(), @@ -75,6 +86,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function PrimitiveInputSchema(): z.ZodObject> { return z.object({ a: z.string().nullish(), @@ -104,6 +125,16 @@ describe('zod', () => { const result = await plugin(schema, [], { schema: 'zod', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function ArrayInputSchema(): z.ZodObject> { return z.object({ a: z.array(z.string().nullable()).nullish(), @@ -134,6 +165,16 @@ describe('zod', () => { const result = await plugin(schema, [], { schema: 'zod', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function AInputSchema(): z.ZodObject> { return z.object({ b: z.lazy(() => BInputSchema()) @@ -171,6 +212,16 @@ describe('zod', () => { const result = await plugin(schema, [], { schema: 'zod', scalars, importFrom: './types', schemaNamespacedImportName: 't' }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function AInputSchema(): z.ZodObject> { return z.object({ b: z.lazy(() => BInputSchema()) @@ -203,6 +254,16 @@ describe('zod', () => { const result = await plugin(schema, [], { schema: 'zod', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function NestedInputSchema(): z.ZodObject> { return z.object({ child: z.lazy(() => NestedInputSchema().nullish()), @@ -227,7 +288,17 @@ describe('zod', () => { const result = await plugin(schema, [], { schema: 'zod', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const PageTypeSchema = z.nativeEnum(PageType); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const PageTypeSchema: z.ZodType = z.nativeEnum(PageType); export function PageInputSchema(): z.ZodObject> { return z.object({ @@ -252,7 +323,17 @@ describe('zod', () => { const result = await plugin(schema, [], { schema: 'zod', scalars, importFrom: './', schemaNamespacedImportName: 't' }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const PageTypeSchema = z.nativeEnum(t.PageType); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const PageTypeSchema: z.ZodType = z.nativeEnum(t.PageType); export function PageInputSchema(): z.ZodObject> { return z.object({ @@ -281,7 +362,17 @@ describe('zod', () => { const result = await plugin(schema, [], { schema: 'zod', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const HttpMethodSchema = z.nativeEnum(HttpMethod); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const HttpMethodSchema: z.ZodType = z.nativeEnum(HttpMethod); export function HttpInputSchema(): z.ZodObject> { return z.object({ @@ -317,6 +408,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SaySchema(): z.ZodObject> { return z.object({ phrase: z.string(), @@ -350,6 +451,16 @@ describe('zod', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SaySchema(): z.ZodObject> { return z.object({ phrase: z.string() @@ -383,6 +494,16 @@ describe('zod', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SaySchema(): z.ZodObject> { return z.object({ phrase: z.string() @@ -416,6 +537,16 @@ describe('zod', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SaySchema(): z.ZodObject> { return z.object({ phrase: z.string() @@ -443,7 +574,17 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const PageTypeSchema = z.enum(['PUBLIC', 'BASIC_AUTH']); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const PageTypeSchema: z.ZodType<"PUBLIC" | "BASIC_AUTH"> = z.enum(['PUBLIC', 'BASIC_AUTH']); " `) }); @@ -468,7 +609,17 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const PageTypeSchema = z.enum(['PUBLIC', 'BASIC_AUTH']); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const PageTypeSchema: z.ZodType<"PUBLIC" | "BASIC_AUTH"> = z.enum(['PUBLIC', 'BASIC_AUTH']); " `) }); @@ -497,6 +648,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function PrimitiveInputSchema(): z.ZodObject> { return z.object({ a: z.string().min(1), @@ -534,6 +695,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function InputOneSchema(): z.ZodObject> { return z.object({ field: z.lazy(() => InputNestedSchema()) @@ -573,6 +744,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function ScalarsInputSchema(): z.ZodObject> { return z.object({ date: z.date(), @@ -608,6 +789,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function ScalarsInputSchema(): z.ZodObject> { return z.object({ date: z.string(), @@ -643,6 +834,16 @@ describe('zod', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function ISaySchema(): z.ZodObject> { return z.object({ phrase: z.string() @@ -676,6 +877,16 @@ describe('zod', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SayISchema(): z.ZodObject> { return z.object({ phrase: z.string() @@ -711,7 +922,7 @@ describe('zod', () => { }, ); - expect(result.content).toContain('export const PageTypeSchema = z.nativeEnum(PageType)'); + expect(result.content).toContain('export const PageTypeSchema: z.ZodType = z.nativeEnum(PageType)'); expect(result.content).toContain('export function PageInputSchema(): z.ZodObject>'); expect(result.content).toContain('pageType: PageTypeSchema.default(PageType.Public)'); @@ -747,7 +958,7 @@ describe('zod', () => { }, ); - expect(result.content).toContain('export const PageTypeSchema = z.nativeEnum(PageType)'); + expect(result.content).toContain('export const PageTypeSchema: z.ZodType = z.nativeEnum(PageType)'); expect(result.content).toContain('export function PageInputSchema(): z.ZodObject>'); expect(result.content).toContain('pageType: PageTypeSchema.default(PageType.Basic_Auth)'); @@ -786,7 +997,7 @@ describe('zod', () => { }, ); - expect(result.content).toContain('export const PageTypeSchema = z.nativeEnum(PageType)'); + expect(result.content).toContain('export const PageTypeSchema: z.ZodType = z.nativeEnum(PageType)'); expect(result.content).toContain('export function PageInputSchema(): z.ZodObject>'); expect(result.content).toContain('pageType: PageTypeSchema.default(PageType.BasicAuth)'); @@ -823,11 +1034,21 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const PageTypeSchema = z.nativeEnum(PageType); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const PageTypeSchema: z.ZodType = z.nativeEnum(PageType); export function PageInputSchema(): z.ZodObject> { return z.object({ - pageType: PageTypeSchema.default("PUBLIC"), + pageType: PageTypeSchema.default(PageType.Public), greeting: z.string().default("Hello").nullish(), newline: z.string().default("Hello\\nWorld").nullish(), score: z.number().default(100).nullish(), @@ -864,6 +1085,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function UserCreateInputSchema(): z.ZodObject> { return z.object({ profile: z.string().min(1, "Please input more than 1").max(5000, "Please input less than 5000").nullish() @@ -897,6 +1128,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function UserCreateInputSchema(): z.ZodObject> { return z.object({ profile: z.string().min(1, "Please input more than 1").max(5000, "Please input less than 5000") @@ -930,6 +1171,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function UserCreateInputSchema(): z.ZodObject> { return z.object({ profile: z.array(z.string().nullable()).min(1, "Please input more than 1").max(5000, "Please input less than 5000").nullish() @@ -966,6 +1217,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function UserCreateInputSchema(): z.ZodObject> { return z.object({ profile: z.string().max(5000, "Please input less than 5000").min(1), @@ -1000,6 +1261,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function UserCreateInputSchema(): z.ZodObject> { return z.object({ profile: z.string().max(5000, "Please input less than 5000"), @@ -1053,6 +1324,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function BookSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Book').optional(), @@ -1130,6 +1411,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function UserCreateInputSchema(): z.ZodObject> { return z.object({ name: z.string(), @@ -1186,6 +1477,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SquareSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Square').optional(), @@ -1232,6 +1533,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SquareSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Square').optional(), @@ -1280,6 +1591,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SquareSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Square').optional(), @@ -1328,6 +1649,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function CircleSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Circle').optional(), @@ -1369,9 +1700,19 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const PageTypeSchema = z.nativeEnum(PageType); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const PageTypeSchema: z.ZodType = z.nativeEnum(PageType); - export const MethodTypeSchema = z.nativeEnum(MethodType); + export const MethodTypeSchema: z.ZodType = z.nativeEnum(MethodType); export function AnyTypeSchema() { return z.union([PageTypeSchema, MethodTypeSchema]) @@ -1408,6 +1749,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export const CircleSchema: z.ZodObject> = z.object({ __typename: z.literal('Circle').optional(), radius: z.number().nullish() @@ -1449,6 +1800,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function MyTypeSchema(): z.ZodObject> { return z.object({ __typename: z.literal('MyType').optional(), @@ -1506,6 +1867,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function BookSchema(): z.ZodObject> { return z.object({ title: z.string().nullish() @@ -1542,6 +1913,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function BookSchema(): z.ZodObject> { return z.object({ author: z.lazy(() => AuthorSchema().nullish()), @@ -1594,6 +1975,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function BookSchema(): z.ZodObject> { return z.object({ title: z.string(), @@ -1657,6 +2048,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function UserCreateInputSchema(): z.ZodObject> { return z.object({ name: z.string().regex(/^Sir/), @@ -1684,6 +2085,16 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export const SaySchema: z.ZodObject> = z.object({ phrase: z.string() }); @@ -1735,6 +2146,16 @@ describe('zod', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export const UserSchema: z.ZodObject> = z.object({ __typename: z.literal('User').optional(), id: z.string(), @@ -1787,7 +2208,17 @@ describe('zod', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - export const TestSchema = z.nativeEnum(Test); + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + + export const TestSchema: z.ZodType = z.nativeEnum(Test); export function QueryInputSchema(): z.ZodObject> { return z.object({ @@ -1820,6 +2251,16 @@ describe('zod', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SaySchema(): z.ZodObject> { return z.object({ phrase: z.string() @@ -1853,6 +2294,16 @@ describe('zod', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " + type Properties = Required<{ + [K in keyof T]: z.ZodType; + }>; + + type definedNonNullAny = {}; + + export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null; + + export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); + export function SaySchema(): z.ZodObject> { return z.object({ phrase: z.string() @@ -1861,4 +2312,563 @@ describe('zod', () => { " `); }); + + describe('open issue coverage', () => { + it('supports configurable nullable field behavior', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserInput { + name: String + age: Int + } + `); + + const nullable = await plugin(schema, [], { schema: 'zod', nullishBehavior: 'nullable' }, {}); + expect(nullable.content).toContain('name: z.string().nullable()'); + expect(nullable.content).toContain('age: z.number().nullable()'); + + const optional = await plugin(schema, [], { schema: 'zod', zodOptionalType: 'optional' }, {}); + expect(optional.content).toContain('name: z.string().optional()'); + expect(optional.content).toContain('age: z.number().optional()'); + }); + + it('applies defaults and directives to non-null arrays', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserCreateInput { + names: [String!]! = [] + tags: [String!]! @constraint(minLength: 1, maxLength: 10) + } + + directive @constraint(minLength: Int!, maxLength: Int!) on INPUT_FIELD_DEFINITION + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + directives: { + constraint: { + minLength: ['min', '$1'], + maxLength: ['max', '$1'], + }, + }, + }, + {}, + ); + + expect(result.content).toContain('names: z.array(z.string()).default([])'); + expect(result.content).toContain('tags: z.array(z.string()).min(1).max(10)'); + }); + + it('supports strict objects and GraphQL descriptions', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserInput { + "Display name shown to users" + name: String! + } + `); + + const result = await plugin( + schema, + [], + { schema: 'zod', strictObjectSchemas: true, withDescriptions: true }, + {}, + ); + + expect(result.content).toContain('name: z.string().describe("Display name shown to users")'); + expect(result.content).toContain('}).strict()'); + }); + + it('respects enumPrefix: false when typesPrefix is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + USER + } + + input CreateUserInput { + role: UserRole! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + importFrom: './types', + typesPrefix: 'I', + enumPrefix: false, + }, + {}, + ); + + expect(result.prepend).toContain(`import { UserRole, ICreateUserInput } from './types'`); + expect(result.content).toContain('export const UserRoleSchema: z.ZodType = z.nativeEnum(UserRole)'); + expect(result.content).toContain('export function ICreateUserInputSchema(): z.ZodObject>'); + }); + + it('qualifies enum defaults when schemaNamespacedImportName is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input PageInput { + pageType: PageType! = PUBLIC + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + importFrom: './types', + schemaNamespacedImportName: 't', + useEnumTypeAsDefaultValue: true, + }, + {}, + ); + + expect(result.content).toContain('pageType: PageTypeSchema.default(t.PageType.Public)'); + }); + + it('keeps native enums as value imports when useTypeImports is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + } + + input CreateUserInput { + role: UserRole! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + importFrom: './types', + useTypeImports: true, + }, + {}, + ); + + expect(result.prepend).toContain(`import { UserRole } from './types'`); + expect(result.prepend).toContain(`import type { CreateUserInput } from './types'`); + }); + + it('generates type-checkable native enum defaults', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input PageInput { + pageType: PageType! = PUBLIC + pageTypes: [PageType!]! = [PUBLIC] + inner: InnerInput! = { pageType: PUBLIC } + nullableNames: [String!] = null + nullableName: String = null + names: [String!]! = [] + } + + input InnerInput { + pageType: PageType! + } + `); + + const result = await plugin(schema, [], { schema: 'zod' }, {}); + + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + export enum PageType { + Public = 'PUBLIC' + } + + export type PageInput = { + pageType: PageType + pageTypes: PageType[] + inner: InnerInput + nullableNames?: string[] | null + nullableName?: string | null + names: string[] + } + + export type InnerInput = { + pageType: PageType + } + + ${result.content} + `); + }); + + it('generates type-checkable null defaults with optional nullable behavior', async () => { + const schema = buildSchema(/* GraphQL */ ` + input PageInput { + nullableNames: [String!] = null + nullableName: String = null + } + `); + + const result = await plugin(schema, [], { schema: 'zod', zodOptionalType: 'optional' }, {}); + + expect(result.content).toContain('nullableNames: z.array(z.string()).nullable().optional().default(null)'); + expect(result.content).toContain('nullableName: z.string().nullable().optional().default(null)'); + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + export type PageInput = { + nullableNames?: string[] | null + nullableName?: string | null + } + + ${result.content} + `); + }); + + it('coerces nested input object defaults from field defaults', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input InnerInput { + pageType: PageType! = PUBLIC + } + + extend input InnerInput { + slug: String = "public" + } + + input PageInput { + inner: InnerInput! = {} + } + `); + + const result = await plugin(schema, [], { schema: 'zod' }, {}); + + expect(result.content).toContain('inner: z.lazy(() => InnerInputSchema().default({ pageType: PageType.Public, slug: "public" }))'); + }); + + it('supports onlyEnums', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + USER + } + + input CreateUserInput { + role: UserRole! + } + `); + + const result = await plugin(schema, [], { schema: 'zod', onlyEnums: true }, {}); + + expect(result.content).toContain('export const UserRoleSchema'); + expect(result.content).not.toContain('CreateUserInputSchema'); + }); + + it('does not generate operation schemas when onlyEnums is enabled', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + } + + type Query { + role: UserRole! + } + `); + const document = parse(/* GraphQL */ ` + query Role { + role + } + `); + + const result = await plugin( + schema, + [{ document }], + { + schema: 'zod', + onlyEnums: true, + withOperationType: true, + }, + {}, + ); + + expect(result.content).toContain('export const UserRoleSchema'); + expect(result.content).not.toContain('RoleQuerySchema'); + }); + + it('orders const object schemas before dependents', async () => { + const schema = buildSchema(/* GraphQL */ ` + type User { + profile: Profile! + } + + type Profile { + name: String! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + withObjectType: true, + validationSchemaExportType: 'const', + }, + {}, + ); + + expect(result.content.indexOf('export const ProfileSchema')).toBeLessThan( + result.content.indexOf('export const UserSchema'), + ); + expect(result.content).toContain('profile: z.lazy(() => ProfileSchema)'); + }); + + it('generates operation schemas from selected query fields', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Author { + email: String! + name: String! + password: String! + } + + type Query { + author: Author! + } + `); + const document = parse(/* GraphQL */ ` + query Author { + author { + email + } + } + `); + + const result = await plugin( + schema, + [{ document }], + { + schema: 'zod', + withOperationType: true, + importFrom: './types', + useTypeImports: true, + }, + {}, + ); + + expect(result.prepend).toContain('import type { AuthorQuery } from \'./types\''); + expect(result.content).toContain('export function AuthorQuerySchema(): z.ZodType'); + expect(result.content).toContain('author: z.object({'); + expect(result.content).toContain('email: z.string()'); + expect(result.content).not.toContain('name: z.string()'); + expect(result.content).not.toContain('password: z.string()'); + }); + + it('qualifies operation result types with namespace imports', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Author { + email: String! + } + + type Query { + author: Author! + } + `); + const document = parse(/* GraphQL */ ` + query Author { + author { + email + } + } + `); + + const result = await plugin( + schema, + [{ document }], + { + schema: 'zod', + withOperationType: true, + importFrom: './types', + schemaNamespacedImportName: 't', + }, + {}, + ); + + expect(result.prepend).toContain('import * as t from \'./types\''); + expect(result.content).toContain('export function AuthorQuerySchema(): z.ZodType'); + }); + + it('generates branch schemas for union operation fragments', async () => { + const schema = buildSchema(/* GraphQL */ ` + type User { + name: String! + } + + type Post { + title: String! + } + + union SearchResult = User | Post + + type Query { + search: [SearchResult!]! + } + `); + const document = parse(/* GraphQL */ ` + query Search { + search { + __typename + ... on User { + name + } + ... on Post { + title + } + } + } + `); + + const result = await plugin(schema, [{ document }], { schema: 'zod', withOperationType: true }, {}); + + expect(result.content).toContain('search: z.array(z.union(['); + expect(result.content).toContain('__typename: z.literal(\'User\')'); + expect(result.content).toContain('name: z.string()'); + expect(result.content).toContain('__typename: z.literal(\'Post\')'); + expect(result.content).toContain('title: z.string()'); + }); + + it('honors conditional operation directives', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Author { + email: String! + name: String! + password: String! + } + + type Query { + author: Author! + } + `); + const document = parse(/* GraphQL */ ` + query Author($showEmail: Boolean!) { + author { + email @include(if: $showEmail) + name @skip(if: true) + password @include(if: false) + } + } + `); + + const result = await plugin(schema, [{ document }], { schema: 'zod', withOperationType: true }, {}); + + expect(result.content).toContain('email: z.string().optional()'); + expect(result.content).not.toContain('name: z.string()'); + expect(result.content).not.toContain('password: z.string()'); + }); + + it('generates type-checkable operation schemas with nonOptionalTypename', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Author { + email: String! + } + + type Query { + author: Author! + } + `); + const document = parse(/* GraphQL */ ` + query Author { + author { + email + } + } + `); + + const result = await plugin( + schema, + [{ document }], + { + schema: 'zod', + withOperationType: true, + nonOptionalTypename: true, + }, + {}, + ); + + expect(result.content).toContain('__typename: z.literal(\'Query\')'); + expect(result.content).toContain('__typename: z.literal(\'Author\')'); + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + export type AuthorQuery = { + __typename: 'Query' + author: { + __typename: 'Author' + email: string + } + } + + ${result.content} + `); + }); + + it('limits circular object validation depth', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Movie { + id: ID! + alternativeTitles: [MovieAlternativeTitle!]! + } + + type MovieAlternativeTitle { + title: String! + movie: Movie! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + withObjectType: true, + maxDepth: 1, + }, + {}, + ); + + expect(result.content).toContain('export function MovieSchema(depth = 0): z.ZodObject>'); + expect(result.content).toContain( + 'alternativeTitles: z.array(z.lazy(() => depth >= 1 ? definedNonNullAnySchema : MovieAlternativeTitleSchema(depth + 1)))', + ); + expect(result.content).toContain('movie: z.lazy(() => depth >= 1 ? definedNonNullAnySchema : MovieSchema(depth + 1))'); + }); + + it('propagates maxDepth through union schemas', async () => { + const schema = buildSchema(/* GraphQL */ ` + type A { + b: U! + } + + union U = B + + type B { + a: A! + } + `); + + const result = await plugin(schema, [], { schema: 'zod', withObjectType: true, maxDepth: 1 }, {}); + + expect(result.content).toContain('export function USchema(depth = 0)'); + expect(result.content).toContain('return BSchema(depth)'); + expect(result.content).toContain('b: z.lazy(() => depth >= 1 ? definedNonNullAnySchema : USchema(depth + 1))'); + expect(result.content).toContain('a: z.lazy(() => depth >= 1 ? definedNonNullAnySchema : ASchema(depth + 1))'); + }); + }); }); diff --git a/tests/zodv4.spec.ts b/tests/zodv4.spec.ts index 9b784890..6c5e1d25 100644 --- a/tests/zodv4.spec.ts +++ b/tests/zodv4.spec.ts @@ -1,7 +1,8 @@ -import { buildClientSchema, buildSchema, introspectionFromSchema } from 'graphql'; +import { buildClientSchema, buildSchema, introspectionFromSchema, parse } from 'graphql'; import { dedent } from 'ts-dedent'; import { plugin } from '../src/index'; +import { expectTypeScriptToCompile } from './typescript-compile'; const initialEmitValue = dedent(` type Properties = Required<{ @@ -44,9 +45,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -85,9 +86,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -124,9 +125,9 @@ describe('zodv4', () => { const result = await plugin(schema, [], { schema: 'zodv4', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -164,9 +165,9 @@ describe('zodv4', () => { const result = await plugin(schema, [], { schema: 'zodv4', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -211,9 +212,9 @@ describe('zodv4', () => { const result = await plugin(schema, [], { schema: 'zodv4', scalars, importFrom: './types', schemaNamespacedImportName: 't' }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -253,9 +254,9 @@ describe('zodv4', () => { const result = await plugin(schema, [], { schema: 'zodv4', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -287,9 +288,9 @@ describe('zodv4', () => { const result = await plugin(schema, [], { schema: 'zodv4', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -297,7 +298,7 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const PageTypeSchema = z.enum(PageType); + export const PageTypeSchema: z.ZodType = z.enum(PageType); export function PageInputSchema(): z.ZodObject> { return z.object({ @@ -322,9 +323,9 @@ describe('zodv4', () => { const result = await plugin(schema, [], { schema: 'zodv4', scalars, importFrom: './', schemaNamespacedImportName: 't' }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -332,7 +333,7 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const PageTypeSchema = z.enum(t.PageType); + export const PageTypeSchema: z.ZodType = z.enum(t.PageType); export function PageInputSchema(): z.ZodObject> { return z.object({ @@ -361,9 +362,9 @@ describe('zodv4', () => { const result = await plugin(schema, [], { schema: 'zodv4', scalars }, {}); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -371,7 +372,7 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const HttpMethodSchema = z.enum(HttpMethod); + export const HttpMethodSchema: z.ZodType = z.enum(HttpMethod); export function HttpInputSchema(): z.ZodObject> { return z.object({ @@ -407,9 +408,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -450,9 +451,9 @@ describe('zodv4', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -493,9 +494,9 @@ describe('zodv4', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -536,9 +537,9 @@ describe('zodv4', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -573,9 +574,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -583,7 +584,7 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const PageTypeSchema = z.enum(['PUBLIC', 'BASIC_AUTH']); + export const PageTypeSchema: z.ZodType<"PUBLIC" | "BASIC_AUTH", "PUBLIC" | "BASIC_AUTH"> = z.enum(['PUBLIC', 'BASIC_AUTH']); " `) }); @@ -608,9 +609,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -618,7 +619,7 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const PageTypeSchema = z.enum(['PUBLIC', 'BASIC_AUTH']); + export const PageTypeSchema: z.ZodType<"PUBLIC" | "BASIC_AUTH", "PUBLIC" | "BASIC_AUTH"> = z.enum(['PUBLIC', 'BASIC_AUTH']); " `) }); @@ -647,9 +648,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -694,9 +695,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -743,9 +744,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -788,9 +789,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -833,9 +834,9 @@ describe('zodv4', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -876,9 +877,9 @@ describe('zodv4', () => { `); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -921,8 +922,8 @@ describe('zodv4', () => { }, ); - expect(result.content).toContain('export const PageTypeSchema = z.enum(PageType)'); - expect(result.content).toContain('export function PageInputSchema(): z.ZodObject>'); + expect(result.content).toContain('export const PageTypeSchema: z.ZodType = z.enum(PageType)'); + expect(result.content).toContain('export function PageInputSchema(): z.ZodType'); expect(result.content).toContain('pageType: PageTypeSchema.default(PageType.Public)'); expect(result.content).toContain('greeting: z.string().default("Hello").nullish()'); @@ -957,8 +958,8 @@ describe('zodv4', () => { }, ); - expect(result.content).toContain('export const PageTypeSchema = z.enum(PageType)'); - expect(result.content).toContain('export function PageInputSchema(): z.ZodObject>'); + expect(result.content).toContain('export const PageTypeSchema: z.ZodType = z.enum(PageType)'); + expect(result.content).toContain('export function PageInputSchema(): z.ZodType'); expect(result.content).toContain('pageType: PageTypeSchema.default(PageType.Basic_Auth)'); expect(result.content).toContain('greeting: z.string().default("Hello").nullish()'); @@ -996,8 +997,8 @@ describe('zodv4', () => { }, ); - expect(result.content).toContain('export const PageTypeSchema = z.enum(PageType)'); - expect(result.content).toContain('export function PageInputSchema(): z.ZodObject>'); + expect(result.content).toContain('export const PageTypeSchema: z.ZodType = z.enum(PageType)'); + expect(result.content).toContain('export function PageInputSchema(): z.ZodType'); expect(result.content).toContain('pageType: PageTypeSchema.default(PageType.BasicAuth)'); expect(result.content).toContain('greeting: z.string().default("Hello").nullish()'); @@ -1033,9 +1034,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1043,11 +1044,11 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const PageTypeSchema = z.enum(PageType); + export const PageTypeSchema: z.ZodType = z.enum(PageType); - export function PageInputSchema(): z.ZodObject> { + export function PageInputSchema(): z.ZodType { return z.object({ - pageType: PageTypeSchema.default("PUBLIC"), + pageType: PageTypeSchema.default(PageType.Public), greeting: z.string().default("Hello").nullish(), newline: z.string().default("Hello\\nWorld").nullish(), score: z.number().default(100).nullish(), @@ -1084,9 +1085,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1127,9 +1128,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1170,9 +1171,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1216,9 +1217,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1260,9 +1261,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1323,9 +1324,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1410,9 +1411,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1476,9 +1477,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1532,9 +1533,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1590,9 +1591,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1648,9 +1649,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1699,9 +1700,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1709,9 +1710,9 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const PageTypeSchema = z.enum(PageType); + export const PageTypeSchema: z.ZodType = z.enum(PageType); - export const MethodTypeSchema = z.enum(MethodType); + export const MethodTypeSchema: z.ZodType = z.enum(MethodType); export function AnyTypeSchema() { return z.union([PageTypeSchema, MethodTypeSchema]) @@ -1748,9 +1749,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1799,9 +1800,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1866,9 +1867,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1912,9 +1913,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -1974,9 +1975,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -2047,9 +2048,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -2084,9 +2085,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -2145,9 +2146,9 @@ describe('zodv4', () => { expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -2207,9 +2208,9 @@ describe('zodv4', () => { ); expect(removedInitialEmitValue(result.content)).toMatchInlineSnapshot(` " - type Properties = Required<{ - [K in keyof T]: z.ZodType; - }>; + type Properties = { + [K in keyof T]: z.ZodType; + }; type definedNonNullAny = {}; @@ -2217,7 +2218,7 @@ describe('zodv4', () => { export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); - export const TestSchema = z.enum(Test); + export const TestSchema: z.ZodType = z.enum(Test); export function QueryInputSchema(): z.ZodObject> { return z.object({ @@ -2227,4 +2228,330 @@ describe('zodv4', () => { " `) }); + + describe('open issue coverage', () => { + it('supports @oneOf input objects', async () => { + const schema = buildSchema(/* GraphQL */ ` + directive @oneOf on INPUT_OBJECT + + input AssignEventInput { + targetId: String! + } + + input PlaceholderEventInput { + placeholder: String! + } + + input EventInput @oneOf { + assignEvent: AssignEventInput + placeholder: PlaceholderEventInput + } + `); + + const result = await plugin(schema, [], { schema: 'zodv4' }, {}); + + expect(result.content).toContain('export function EventInputSchema(): z.ZodType'); + expect(result.content).toContain('assignEvent: z.lazy(() => AssignEventInputSchema())'); + expect(result.content).toContain('placeholder: z.never().optional()'); + expect(result.content).toContain('placeholder: z.lazy(() => PlaceholderEventInputSchema())'); + expect(result.content).toContain('assignEvent: z.never().optional()'); + }); + + it('supports configurable nullable field behavior', async () => { + const schema = buildSchema(/* GraphQL */ ` + input UserInput { + name: String + } + `); + + const result = await plugin(schema, [], { schema: 'zodv4', nullishBehavior: 'nullable' }, {}); + expect(result.content).toContain('name: z.string().nullable()'); + }); + + it('qualifies enum defaults when schemaNamespacedImportName is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input PageInput { + pageType: PageType! = PUBLIC + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zodv4', + importFrom: './types', + schemaNamespacedImportName: 't', + useEnumTypeAsDefaultValue: true, + }, + {}, + ); + + expect(result.content).toContain('pageType: PageTypeSchema.default(t.PageType.Public)'); + }); + + it('keeps native enums as value imports when useTypeImports is configured', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum UserRole { + ADMIN + } + + input CreateUserInput { + role: UserRole! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zodv4', + importFrom: './types', + useTypeImports: true, + }, + {}, + ); + + expect(result.prepend).toContain(`import { UserRole } from './types'`); + expect(result.prepend).toContain(`import type { CreateUserInput } from './types'`); + }); + + it('generates type-checkable native enum and array defaults', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input PageInput { + pageType: PageType! = PUBLIC + pageTypes: [PageType!]! = [PUBLIC] + inner: InnerInput! = { pageType: PUBLIC } + nullableNames: [String!] = null + nullableName: String = null + greeting: String = "Hello" + names: [String!]! = [] + } + + input InnerInput { + pageType: PageType! + } + `); + + const result = await plugin(schema, [], { schema: 'zodv4' }, {}); + + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + export enum PageType { + Public = 'PUBLIC' + } + + export type PageInput = { + pageType: PageType + pageTypes: PageType[] + inner: InnerInput + nullableNames?: string[] | null + nullableName?: string | null + greeting?: string | null + names: string[] + } + + export type InnerInput = { + pageType: PageType + } + + ${result.content} + `); + }); + + it('generates type-checkable null defaults with optional nullable behavior', async () => { + const schema = buildSchema(/* GraphQL */ ` + input PageInput { + nullableNames: [String!] = null + nullableName: String = null + } + `); + + const result = await plugin(schema, [], { schema: 'zodv4', zodOptionalType: 'optional' }, {}); + + expect(result.content).toContain('nullableNames: z.array(z.string()).nullable().optional().default(null)'); + expect(result.content).toContain('nullableName: z.string().nullable().optional().default(null)'); + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + export type PageInput = { + nullableNames?: string[] | null + nullableName?: string | null + } + + ${result.content} + `); + }); + + it('coerces nested input object defaults from field defaults', async () => { + const schema = buildSchema(/* GraphQL */ ` + enum PageType { + PUBLIC + } + + input InnerInput { + pageType: PageType! = PUBLIC + } + + extend input InnerInput { + slug: String = "public" + } + + input PageInput { + inner: InnerInput! = {} + } + `); + + const result = await plugin(schema, [], { schema: 'zodv4' }, {}); + + expect(result.content).toContain('inner: z.lazy(() => InnerInputSchema().default({ pageType: PageType.Public, slug: "public" }))'); + }); + + it('generates operation schemas from selected query fields', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Author { + email: String! + name: String! + password: String! + } + + type Query { + author: Author! + } + `); + const document = parse(/* GraphQL */ ` + query Author { + author { + email + } + } + `); + + const result = await plugin( + schema, + [{ document }], + { + schema: 'zodv4', + withOperationType: true, + importFrom: './types', + useTypeImports: true, + }, + {}, + ); + + expect(result.prepend).toContain('import type { AuthorQuery } from \'./types\''); + expect(result.content).toContain('export function AuthorQuerySchema(): z.ZodType'); + expect(result.content).toContain('author: z.object({'); + expect(result.content).toContain('email: z.string()'); + expect(result.content).not.toContain('name: z.string()'); + expect(result.content).not.toContain('password: z.string()'); + }); + + it('generates type-checkable operation schemas with nonOptionalTypename', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Author { + email: String! + } + + type Query { + author: Author! + } + `); + const document = parse(/* GraphQL */ ` + query Author { + author { + email + } + } + `); + + const result = await plugin( + schema, + [{ document }], + { + schema: 'zodv4', + withOperationType: true, + nonOptionalTypename: true, + }, + {}, + ); + + expect(result.content).toContain('__typename: z.literal(\'Query\')'); + expect(result.content).toContain('__typename: z.literal(\'Author\')'); + expectTypeScriptToCompile(` + ${(result.prepend ?? []).join('\n')} + + export type AuthorQuery = { + __typename: 'Query' + author: { + __typename: 'Author' + email: string + } + } + + ${result.content} + `); + }); + + it('limits circular object validation depth', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Movie { + id: ID! + alternativeTitles: [MovieAlternativeTitle!]! + } + + type MovieAlternativeTitle { + title: String! + movie: Movie! + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zodv4', + withObjectType: true, + maxDepth: 1, + }, + {}, + ); + + expect(result.content).toContain('export function MovieSchema(depth = 0): z.ZodObject>'); + expect(result.content).toContain( + 'alternativeTitles: z.array(z.lazy(() => depth >= 1 ? definedNonNullAnySchema : MovieAlternativeTitleSchema(depth + 1)))', + ); + expect(result.content).toContain('movie: z.lazy(() => depth >= 1 ? definedNonNullAnySchema : MovieSchema(depth + 1))'); + }); + + it('propagates maxDepth through union schemas', async () => { + const schema = buildSchema(/* GraphQL */ ` + type A { + b: U! + } + + union U = B + + type B { + a: A! + } + `); + + const result = await plugin(schema, [], { schema: 'zodv4', withObjectType: true, maxDepth: 1 }, {}); + + expect(result.content).toContain('export function USchema(depth = 0)'); + expect(result.content).toContain('return BSchema(depth)'); + expect(result.content).toContain('b: z.lazy(() => depth >= 1 ? definedNonNullAnySchema : USchema(depth + 1))'); + expect(result.content).toContain('a: z.lazy(() => depth >= 1 ? definedNonNullAnySchema : ASchema(depth + 1))'); + }); + }); });