diff --git a/codegen.yml b/codegen.yml index b35f9fd3..b8b02819 100644 --- a/codegen.yml +++ b/codegen.yml @@ -13,6 +13,7 @@ generates: schema: yup importFrom: ../types withObjectType: true + validationSchemaExportType: const directives: required: msg: required @@ -49,6 +50,7 @@ generates: schema: zod importFrom: ../types withObjectType: true + validationSchemaExportType: const directives: # Write directives like # @@ -72,6 +74,7 @@ generates: schema: myzod importFrom: ../types withObjectType: true + validationSchemaExportType: const directives: constraint: minLength: min diff --git a/example/myzod/schemas.ts b/example/myzod/schemas.ts index 36b9d2b7..c8526499 100644 --- a/example/myzod/schemas.ts +++ b/example/myzod/schemas.ts @@ -1,110 +1,86 @@ import * as myzod from 'myzod' -import { Admin, AttributeInput, ButtonComponentType, ComponentInput, DropDownComponentInput, EventArgumentInput, EventInput, EventOptionType, Guest, HttpInput, HttpMethod, LayoutInput, PageInput, PageType, User } from '../types' +import { PageType, HttpMethod, HttpInput, EventOptionType, EventArgumentInput, EventInput, ComponentInput, DropDownComponentInput, LayoutInput, ButtonComponentType, AttributeInput, PageInput, Guest, Admin, User } from '../types' export const definedNonNullAnySchema = myzod.object({}); -export function AdminSchema(): myzod.Type { - return myzod.object({ - __typename: myzod.literal('Admin').optional(), - lastModifiedAt: definedNonNullAnySchema.optional().nullable() - }) -} +export const PageTypeSchema = myzod.enum(PageType); -export function AttributeInputSchema(): myzod.Type { - return myzod.object({ - key: myzod.string().optional().nullable(), - val: myzod.string().optional().nullable() - }) -} +export const HttpMethodSchema = myzod.enum(HttpMethod); + +export const EventOptionTypeSchema = myzod.enum(EventOptionType); export const ButtonComponentTypeSchema = myzod.enum(ButtonComponentType); -export function ComponentInputSchema(): myzod.Type { - return myzod.object({ - child: myzod.lazy(() => ComponentInputSchema().optional().nullable()), - childrens: myzod.array(myzod.lazy(() => ComponentInputSchema().nullable())).optional().nullable(), - event: myzod.lazy(() => EventInputSchema().optional().nullable()), - name: myzod.string(), - type: ButtonComponentTypeSchema - }) -} - -export function DropDownComponentInputSchema(): myzod.Type { - return myzod.object({ - dropdownComponent: myzod.lazy(() => ComponentInputSchema().optional().nullable()), - getEvent: myzod.lazy(() => EventInputSchema()) - }) -} - -export function EventArgumentInputSchema(): myzod.Type { - return myzod.object({ +export const HttpInputSchema: myzod.Type = myzod.object({ + method: HttpMethodSchema.optional().nullable(), + url: definedNonNullAnySchema +}); + +export const EventArgumentInputSchema: myzod.Type = myzod.object({ name: myzod.string().min(5), value: myzod.string().pattern(/^foo/) - }) -} +}); -export function EventInputSchema(): myzod.Type { - return myzod.object({ - arguments: myzod.array(myzod.lazy(() => EventArgumentInputSchema())), +export const EventInputSchema: myzod.Type = myzod.object({ + arguments: myzod.array(myzod.lazy(() => EventArgumentInputSchema)), options: myzod.array(EventOptionTypeSchema).optional().nullable() - }) -} - -export const EventOptionTypeSchema = myzod.enum(EventOptionType); +}); -export function GuestSchema(): myzod.Type { - return myzod.object({ - __typename: myzod.literal('Guest').optional(), - lastLoggedIn: definedNonNullAnySchema.optional().nullable() - }) -} +export const ComponentInputSchema: myzod.Type = myzod.object({ + child: myzod.lazy(() => ComponentInputSchema.optional().nullable()), + childrens: myzod.array(myzod.lazy(() => ComponentInputSchema.nullable())).optional().nullable(), + event: myzod.lazy(() => EventInputSchema.optional().nullable()), + name: myzod.string(), + type: ButtonComponentTypeSchema +}); -export function HttpInputSchema(): myzod.Type { - return myzod.object({ - method: HttpMethodSchema.optional().nullable(), - url: definedNonNullAnySchema - }) -} +export const DropDownComponentInputSchema: myzod.Type = myzod.object({ + dropdownComponent: myzod.lazy(() => ComponentInputSchema.optional().nullable()), + getEvent: myzod.lazy(() => EventInputSchema) +}); -export const HttpMethodSchema = myzod.enum(HttpMethod); +export const LayoutInputSchema: myzod.Type = myzod.object({ + dropdown: myzod.lazy(() => DropDownComponentInputSchema.optional().nullable()) +}); -export function LayoutInputSchema(): myzod.Type { - return myzod.object({ - dropdown: myzod.lazy(() => DropDownComponentInputSchema().optional().nullable()) - }) -} +export const AttributeInputSchema: myzod.Type = myzod.object({ + key: myzod.string().optional().nullable(), + val: myzod.string().optional().nullable() +}); -export function PageInputSchema(): myzod.Type { - return myzod.object({ - attributes: myzod.array(myzod.lazy(() => AttributeInputSchema())).optional().nullable(), +export const PageInputSchema: myzod.Type = myzod.object({ + attributes: myzod.array(myzod.lazy(() => AttributeInputSchema)).optional().nullable(), date: definedNonNullAnySchema.optional().nullable(), height: myzod.number(), id: myzod.string(), - layout: myzod.lazy(() => LayoutInputSchema()), + layout: myzod.lazy(() => LayoutInputSchema), pageType: PageTypeSchema, postIDs: myzod.array(myzod.string()).optional().nullable(), show: myzod.boolean(), tags: myzod.array(myzod.string().nullable()).optional().nullable(), title: myzod.string(), width: myzod.number() - }) -} +}); -export const PageTypeSchema = myzod.enum(PageType); +export const GuestSchema: myzod.Type = myzod.object({ + __typename: myzod.literal('Guest').optional(), + lastLoggedIn: definedNonNullAnySchema.optional().nullable() +}); + +export const AdminSchema: myzod.Type = myzod.object({ + __typename: myzod.literal('Admin').optional(), + lastModifiedAt: definedNonNullAnySchema.optional().nullable() +}); -export function UserSchema(): myzod.Type { - return myzod.object({ +export const UserKindSchema = myzod.union([AdminSchema, GuestSchema]); + +export const UserSchema: myzod.Type = myzod.object({ __typename: myzod.literal('User').optional(), createdAt: definedNonNullAnySchema.optional().nullable(), email: myzod.string().optional().nullable(), id: myzod.string().optional().nullable(), - kind: UserKindSchema().optional().nullable(), + kind: UserKindSchema.optional().nullable(), name: myzod.string().optional().nullable(), password: myzod.string().optional().nullable(), updatedAt: definedNonNullAnySchema.optional().nullable() - }) -} - -export function UserKindSchema() { - return myzod.union([AdminSchema(), GuestSchema()]) -} +}); diff --git a/example/yup/schemas.ts b/example/yup/schemas.ts index 240f426c..2a38d5d6 100644 --- a/example/yup/schemas.ts +++ b/example/yup/schemas.ts @@ -1,114 +1,90 @@ import * as yup from 'yup' -import { Admin, AttributeInput, ButtonComponentType, ComponentInput, DropDownComponentInput, EventArgumentInput, EventInput, EventOptionType, Guest, HttpInput, HttpMethod, LayoutInput, PageInput, PageType, User, UserKind } from '../types' +import { PageType, HttpMethod, HttpInput, EventOptionType, EventArgumentInput, EventInput, ComponentInput, DropDownComponentInput, LayoutInput, ButtonComponentType, AttributeInput, PageInput, Guest, Admin, UserKind, User } from '../types' -function union(...schemas: ReadonlyArray>): yup.MixedSchema { - return yup.mixed().test({ - test: (value) => schemas.some((schema) => schema.isValidSync(value)) - }).defined() -} +export const PageTypeSchema = yup.string().oneOf([PageType.BasicAuth, PageType.Lp, PageType.Restricted, PageType.Service]).defined(); -export function AdminSchema(): yup.ObjectSchema { - return yup.object({ - __typename: yup.string<'Admin'>().optional(), - lastModifiedAt: yup.mixed().nullable().optional() - }) -} +export const HttpMethodSchema = yup.string().oneOf([HttpMethod.Get, HttpMethod.Post]).defined(); -export function AttributeInputSchema(): yup.ObjectSchema { - return yup.object({ - key: yup.string().defined().nullable().optional(), - val: yup.string().defined().nullable().optional() - }) -} +export const EventOptionTypeSchema = yup.string().oneOf([EventOptionType.Reload, EventOptionType.Retry]).defined(); export const ButtonComponentTypeSchema = yup.string().oneOf([ButtonComponentType.Button, ButtonComponentType.Submit]).defined(); -export function ComponentInputSchema(): yup.ObjectSchema { - return yup.object({ - child: yup.lazy(() => ComponentInputSchema()).optional(), - childrens: yup.array(yup.lazy(() => ComponentInputSchema())).defined().nullable().optional(), - event: yup.lazy(() => EventInputSchema()).optional(), - name: yup.string().defined().nonNullable(), - type: ButtonComponentTypeSchema.nonNullable() - }) +function union(...schemas: ReadonlyArray>): yup.MixedSchema { + return yup.mixed().test({ + test: (value) => schemas.some((schema) => schema.isValidSync(value)) + }).defined() } -export function DropDownComponentInputSchema(): yup.ObjectSchema { - return yup.object({ - dropdownComponent: yup.lazy(() => ComponentInputSchema()).optional(), - getEvent: yup.lazy(() => EventInputSchema().nonNullable()) - }) -} +export const HttpInputSchema: yup.ObjectSchema = yup.object({ + method: HttpMethodSchema.nullable().optional(), + url: yup.mixed().nonNullable() +}); -export function EventArgumentInputSchema(): yup.ObjectSchema { - return yup.object({ +export const EventArgumentInputSchema: yup.ObjectSchema = yup.object({ name: yup.string().defined().nonNullable().min(5), value: yup.string().defined().nonNullable().matches(/^foo/) - }) -} +}); -export function EventInputSchema(): yup.ObjectSchema { - return yup.object({ - arguments: yup.array(yup.lazy(() => EventArgumentInputSchema().nonNullable())).defined(), +export const EventInputSchema: yup.ObjectSchema = yup.object({ + arguments: yup.array(yup.lazy(() => EventArgumentInputSchema.nonNullable())).defined(), options: yup.array(EventOptionTypeSchema.nonNullable()).defined().nullable().optional() - }) -} +}); -export const EventOptionTypeSchema = yup.string().oneOf([EventOptionType.Reload, EventOptionType.Retry]).defined(); +export const ComponentInputSchema: yup.ObjectSchema = yup.object({ + child: yup.lazy(() => ComponentInputSchema).optional(), + childrens: yup.array(yup.lazy(() => ComponentInputSchema)).defined().nullable().optional(), + event: yup.lazy(() => EventInputSchema).optional(), + name: yup.string().defined().nonNullable(), + type: ButtonComponentTypeSchema.nonNullable() +}); -export function GuestSchema(): yup.ObjectSchema { - return yup.object({ - __typename: yup.string<'Guest'>().optional(), - lastLoggedIn: yup.mixed().nullable().optional() - }) -} +export const DropDownComponentInputSchema: yup.ObjectSchema = yup.object({ + dropdownComponent: yup.lazy(() => ComponentInputSchema).optional(), + getEvent: yup.lazy(() => EventInputSchema.nonNullable()) +}); -export function HttpInputSchema(): yup.ObjectSchema { - return yup.object({ - method: HttpMethodSchema.nullable().optional(), - url: yup.mixed().nonNullable() - }) -} - -export const HttpMethodSchema = yup.string().oneOf([HttpMethod.Get, HttpMethod.Post]).defined(); +export const LayoutInputSchema: yup.ObjectSchema = yup.object({ + dropdown: yup.lazy(() => DropDownComponentInputSchema).optional() +}); -export function LayoutInputSchema(): yup.ObjectSchema { - return yup.object({ - dropdown: yup.lazy(() => DropDownComponentInputSchema()).optional() - }) -} +export const AttributeInputSchema: yup.ObjectSchema = yup.object({ + key: yup.string().defined().nullable().optional(), + val: yup.string().defined().nullable().optional() +}); -export function PageInputSchema(): yup.ObjectSchema { - return yup.object({ - attributes: yup.array(yup.lazy(() => AttributeInputSchema().nonNullable())).defined().nullable().optional(), +export const PageInputSchema: yup.ObjectSchema = yup.object({ + attributes: yup.array(yup.lazy(() => AttributeInputSchema.nonNullable())).defined().nullable().optional(), date: yup.mixed().nullable().optional(), height: yup.number().defined().nonNullable(), id: yup.string().defined().nonNullable(), - layout: yup.lazy(() => LayoutInputSchema().nonNullable()), + layout: yup.lazy(() => LayoutInputSchema.nonNullable()), pageType: PageTypeSchema.nonNullable(), postIDs: yup.array(yup.string().defined().nonNullable()).defined().nullable().optional(), show: yup.boolean().defined().nonNullable(), tags: yup.array(yup.string().defined().nullable()).defined().nullable().optional(), title: yup.string().defined().nonNullable(), width: yup.number().defined().nonNullable() - }) -} +}); -export const PageTypeSchema = yup.string().oneOf([PageType.BasicAuth, PageType.Lp, PageType.Restricted, PageType.Service]).defined(); +export const GuestSchema: yup.ObjectSchema = yup.object({ + __typename: yup.string<'Guest'>().optional(), + lastLoggedIn: yup.mixed().nullable().optional() +}); + +export const AdminSchema: yup.ObjectSchema = yup.object({ + __typename: yup.string<'Admin'>().optional(), + lastModifiedAt: yup.mixed().nullable().optional() +}); + +export const UserKindSchema: yup.MixedSchema = union(AdminSchema, GuestSchema); -export function UserSchema(): yup.ObjectSchema { - return yup.object({ +export const UserSchema: yup.ObjectSchema = yup.object({ __typename: yup.string<'User'>().optional(), createdAt: yup.mixed().nullable().optional(), email: yup.string().defined().nullable().optional(), id: yup.string().defined().nullable().optional(), - kind: UserKindSchema().nullable().optional(), + kind: UserKindSchema.nullable().optional(), name: yup.string().defined().nullable().optional(), password: yup.string().defined().nullable().optional(), updatedAt: yup.mixed().nullable().optional() - }) -} - -export function UserKindSchema(): yup.MixedSchema { - return union(AdminSchema(), GuestSchema()) -} +}); diff --git a/example/zod/schemas.ts b/example/zod/schemas.ts index 3b294d28..4f23a70c 100644 --- a/example/zod/schemas.ts +++ b/example/zod/schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { Admin, AttributeInput, ButtonComponentType, ComponentInput, DropDownComponentInput, EventArgumentInput, EventInput, EventOptionType, Guest, HttpInput, HttpMethod, LayoutInput, PageInput, PageType, User } from '../types' +import { PageType, HttpMethod, HttpInput, EventOptionType, EventArgumentInput, EventInput, ComponentInput, DropDownComponentInput, LayoutInput, ButtonComponentType, AttributeInput, PageInput, Guest, Admin, User } from '../types' type Properties = Required<{ [K in keyof T]: z.ZodType; @@ -11,108 +11,84 @@ export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== und export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v)); -export function AdminSchema(): z.ZodObject> { - return z.object>({ - __typename: z.literal('Admin').optional(), - lastModifiedAt: definedNonNullAnySchema.nullish() - }) -} +export const PageTypeSchema = z.nativeEnum(PageType); -export function AttributeInputSchema(): z.ZodObject> { - return z.object>({ - key: z.string().nullish(), - val: z.string().nullish() - }) -} +export const HttpMethodSchema = z.nativeEnum(HttpMethod); + +export const EventOptionTypeSchema = z.nativeEnum(EventOptionType); export const ButtonComponentTypeSchema = z.nativeEnum(ButtonComponentType); -export function ComponentInputSchema(): z.ZodObject> { - return z.object>({ - child: z.lazy(() => ComponentInputSchema().nullish()), - childrens: z.array(z.lazy(() => ComponentInputSchema().nullable())).nullish(), - event: z.lazy(() => EventInputSchema().nullish()), - name: z.string(), - type: ButtonComponentTypeSchema - }) -} - -export function DropDownComponentInputSchema(): z.ZodObject> { - return z.object>({ - dropdownComponent: z.lazy(() => ComponentInputSchema().nullish()), - getEvent: z.lazy(() => EventInputSchema()) - }) -} - -export function EventArgumentInputSchema(): z.ZodObject> { - return z.object>({ +export const HttpInputSchema: z.ZodObject> = z.object({ + method: HttpMethodSchema.nullish(), + url: definedNonNullAnySchema +}); + +export const EventArgumentInputSchema: z.ZodObject> = z.object({ name: z.string().min(5), value: z.string().regex(/^foo/, "message") - }) -} +}); -export function EventInputSchema(): z.ZodObject> { - return z.object>({ - arguments: z.array(z.lazy(() => EventArgumentInputSchema())), +export const EventInputSchema: z.ZodObject> = z.object({ + arguments: z.array(z.lazy(() => EventArgumentInputSchema)), options: z.array(EventOptionTypeSchema).nullish() - }) -} - -export const EventOptionTypeSchema = z.nativeEnum(EventOptionType); +}); -export function GuestSchema(): z.ZodObject> { - return z.object>({ - __typename: z.literal('Guest').optional(), - lastLoggedIn: definedNonNullAnySchema.nullish() - }) -} +export const ComponentInputSchema: z.ZodObject> = z.object({ + child: z.lazy(() => ComponentInputSchema.nullish()), + childrens: z.array(z.lazy(() => ComponentInputSchema.nullable())).nullish(), + event: z.lazy(() => EventInputSchema.nullish()), + name: z.string(), + type: ButtonComponentTypeSchema +}); -export function HttpInputSchema(): z.ZodObject> { - return z.object>({ - method: HttpMethodSchema.nullish(), - url: definedNonNullAnySchema - }) -} +export const DropDownComponentInputSchema: z.ZodObject> = z.object({ + dropdownComponent: z.lazy(() => ComponentInputSchema.nullish()), + getEvent: z.lazy(() => EventInputSchema) +}); -export const HttpMethodSchema = z.nativeEnum(HttpMethod); +export const LayoutInputSchema: z.ZodObject> = z.object({ + dropdown: z.lazy(() => DropDownComponentInputSchema.nullish()) +}); -export function LayoutInputSchema(): z.ZodObject> { - return z.object>({ - dropdown: z.lazy(() => DropDownComponentInputSchema().nullish()) - }) -} +export const AttributeInputSchema: z.ZodObject> = z.object({ + key: z.string().nullish(), + val: z.string().nullish() +}); -export function PageInputSchema(): z.ZodObject> { - return z.object>({ - attributes: z.array(z.lazy(() => AttributeInputSchema())).nullish(), +export const PageInputSchema: z.ZodObject> = z.object({ + attributes: z.array(z.lazy(() => AttributeInputSchema)).nullish(), date: definedNonNullAnySchema.nullish(), height: z.number(), id: z.string(), - layout: z.lazy(() => LayoutInputSchema()), + layout: z.lazy(() => LayoutInputSchema), pageType: PageTypeSchema, postIDs: z.array(z.string()).nullish(), show: z.boolean(), tags: z.array(z.string().nullable()).nullish(), title: z.string(), width: z.number() - }) -} +}); -export const PageTypeSchema = z.nativeEnum(PageType); +export const GuestSchema: z.ZodObject> = z.object({ + __typename: z.literal('Guest').optional(), + lastLoggedIn: definedNonNullAnySchema.nullish() +}); + +export const AdminSchema: z.ZodObject> = z.object({ + __typename: z.literal('Admin').optional(), + lastModifiedAt: definedNonNullAnySchema.nullish() +}); -export function UserSchema(): z.ZodObject> { - return z.object>({ +export const UserKindSchema = z.union([AdminSchema, GuestSchema]); + +export const UserSchema: z.ZodObject> = z.object({ __typename: z.literal('User').optional(), createdAt: definedNonNullAnySchema.nullish(), email: z.string().nullish(), id: z.string().nullish(), - kind: UserKindSchema().nullish(), + kind: UserKindSchema.nullish(), name: z.string().nullish(), password: z.string().nullish(), updatedAt: definedNonNullAnySchema.nullish() - }) -} - -export function UserKindSchema() { - return z.union([AdminSchema(), GuestSchema()]) -} +}); diff --git a/package.json b/package.json index a162b7e1..1e0c5e78 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@graphql-codegen/cli": "4.0.1", "@graphql-codegen/typescript": "^4.0.0", "@tsconfig/recommended": "1.0.2", + "@types/graphlib": "^2.1.8", "@types/jest": "29.5.2", "@types/node": "^20.1.3", "@typescript-eslint/eslint-plugin": "5.59.8", @@ -59,6 +60,7 @@ "myzod": "1.10.0", "npm-run-all": "4.1.5", "prettier": "2.8.8", + "ts-dedent": "^2.2.0", "ts-jest": "29.1.0", "typescript": "5.1.3", "yup": "1.2.0", @@ -69,6 +71,7 @@ "@graphql-codegen/schema-ast": "4.0.0", "@graphql-codegen/visitor-plugin-common": "^4.0.0", "@graphql-tools/utils": "^10.0.0", + "graphlib": "^2.1.8", "graphql": "^16.6.0" }, "peerDependencies": { diff --git a/src/config.ts b/src/config.ts index 967c7e06..3fd32e7c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { TypeScriptPluginConfig } from '@graphql-codegen/typescript'; export type ValidationSchema = 'yup' | 'zod' | 'myzod'; +export type ValidationSchemaExportType = 'function' | 'const'; export interface DirectiveConfig { [directive: string]: { @@ -234,4 +235,20 @@ export interface ValidationSchemaPluginConfig extends TypeScriptPluginConfig { * ``` */ directives?: DirectiveConfig; + /** + * @description Specify validation schema export type + * @default function + * + * @exampleMarkdown + * ```yml + * generates: + * path/to/file.ts: + * plugins: + * - typescript + * - graphql-codegen-validation-schema + * config: + * validationSchemaExportType: const + * ``` + */ + validationSchemaExportType?: ValidationSchemaExportType; } diff --git a/src/graphql.ts b/src/graphql.ts index 9877d92c..f1c65cea 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -1,4 +1,17 @@ -import { ListTypeNode, NonNullTypeNode, NamedTypeNode, TypeNode, ObjectTypeDefinitionNode } from 'graphql'; +import { + ListTypeNode, + NonNullTypeNode, + NamedTypeNode, + TypeNode, + ObjectTypeDefinitionNode, + visit, + DocumentNode, + DefinitionNode, + NameNode, + ASTNode, + GraphQLSchema, +} from 'graphql'; +import { Graph } from 'graphlib'; export const isListType = (typ?: TypeNode): typ is ListTypeNode => typ?.kind === 'ListType'; export const isNonNullType = (typ?: TypeNode): typ is NonNullTypeNode => typ?.kind === 'NonNullType'; @@ -20,3 +33,139 @@ export const ObjectTypeDefinitionBuilder = ( return callback(node); }; }; + +export const topologicalSortAST = (schema: GraphQLSchema, ast: DocumentNode): DocumentNode => { + const dependencyGraph = new Graph(); + const targetKinds = [ + 'ObjectTypeDefinition', + 'InputObjectTypeDefinition', + 'EnumTypeDefinition', + 'UnionTypeDefinition', + 'ScalarTypeDefinition', + ]; + + visit(ast, { + enter: node => { + switch (node.kind) { + case 'ObjectTypeDefinition': + case 'InputObjectTypeDefinition': { + const typeName = node.name.value; + dependencyGraph.setNode(typeName); + + if (node.fields) { + node.fields.forEach(field => { + if (field.type.kind === 'NamedType') { + const dependency = field.type.name.value; + const typ = schema.getType(dependency); + if (typ?.astNode?.kind === undefined || !targetKinds.includes(typ.astNode.kind)) { + return; + } + if (!dependencyGraph.hasNode(dependency)) { + dependencyGraph.setNode(dependency); + } + dependencyGraph.setEdge(typeName, dependency); + } + }); + } + break; + } + case 'ScalarTypeDefinition': + case 'EnumTypeDefinition': { + dependencyGraph.setNode(node.name.value); + break; + } + case 'UnionTypeDefinition': { + const dependency = node.name.value; + if (!dependencyGraph.hasNode(dependency)) { + dependencyGraph.setNode(dependency); + } + node.types?.forEach(type => { + const dependency = type.name.value; + const typ = schema.getType(dependency); + if (typ?.astNode?.kind === undefined || !targetKinds.includes(typ.astNode.kind)) { + return; + } + dependencyGraph.setEdge(node.name.value, dependency); + }); + break; + } + default: + break; + } + }, + }); + + const sorted = topsort(dependencyGraph); + + // Create a map of definitions for quick access, using the definition's name as the key. + const definitionsMap: Map = new Map(); + + // SCHEMA_DEFINITION does not have name. + // https://spec.graphql.org/October2021/#sec-Schema + const astDefinitions = ast.definitions.filter(def => def.kind !== 'SchemaDefinition'); + + astDefinitions.forEach(definition => { + if (hasNameField(definition) && definition.name) { + definitionsMap.set(definition.name.value, definition); + } + }); + + // Two arrays to store sorted and not sorted definitions. + const sortedDefinitions: DefinitionNode[] = []; + const notSortedDefinitions: DefinitionNode[] = []; + + // Iterate over sorted type names and retrieve their corresponding definitions. + sorted.forEach(sortedType => { + const definition = definitionsMap.get(sortedType); + if (definition) { + sortedDefinitions.push(definition); + definitionsMap.delete(sortedType); + } + }); + + // Definitions that are left in the map were not included in sorted list + // Add them to notSortedDefinitions. + definitionsMap.forEach(definition => notSortedDefinitions.push(definition)); + + const newDefinitions = [...sortedDefinitions, ...notSortedDefinitions]; + + if (newDefinitions.length !== astDefinitions.length) { + throw new Error( + `unexpected definition length after sorted: want ${astDefinitions.length} but got ${newDefinitions.length}` + ); + } + + return { + ...ast, + definitions: newDefinitions as ReadonlyArray, + }; +}; + +const hasNameField = (node: ASTNode): node is DefinitionNode & { name?: NameNode } => { + return 'name' in node; +}; + +// Re-implemented w/o CycleException version +// https://github.com/dagrejs/graphlib/blob/8d27cb89029081c72eb89dde652602805bdd0a34/lib/alg/topsort.js +export const topsort = (g: Graph): string[] => { + const visited: Record = {}; + const stack: Record = {}; + const results: any[] = []; + + function visit(node: string) { + if (!(node in visited)) { + stack[node] = true; + visited[node] = true; + const predecessors = g.predecessors(node); + if (Array.isArray(predecessors)) { + predecessors.forEach(node => visit(node)); + } + delete stack[node]; + results.push(node); + } + } + + g.sinks().forEach(node => visit(node)); + + return results.reverse(); +}; diff --git a/src/index.ts b/src/index.ts index c8168c9c..c61c5cb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,13 +6,14 @@ import { ValidationSchemaPluginConfig } from './config'; import { PluginFunction, Types } from '@graphql-codegen/plugin-helpers'; import { GraphQLSchema, visit } from 'graphql'; import { SchemaVisitor } from './types'; +import { topologicalSortAST } from './graphql'; export const plugin: PluginFunction = ( schema: GraphQLSchema, _documents: Types.DocumentFile[], config: ValidationSchemaPluginConfig ): Types.ComplexPluginOutput => { - const { schema: _schema, ast } = transformSchemaAST(schema, config); + const { schema: _schema, ast } = _transformSchemaAST(schema, config); const { buildImports, initialEmit, ...visitor } = schemaVisitor(_schema, config); const result = visit(ast, visitor); @@ -35,3 +36,19 @@ const schemaVisitor = (schema: GraphQLSchema, config: ValidationSchemaPluginConf } return YupSchemaVisitor(schema, config); }; + +const _transformSchemaAST = (schema: GraphQLSchema, config: ValidationSchemaPluginConfig) => { + const { schema: _schema, ast } = transformSchemaAST(schema, config); + // This affects the performance of code generation, so it is + // enabled only when this option is selected. + if (config.validationSchemaExportType === 'const') { + return { + schema: _schema, + ast: topologicalSortAST(_schema, ast), + }; + } + return { + schema: _schema, + ast, + }; +}; diff --git a/src/myzod/index.ts b/src/myzod/index.ts index d96692df..956190d4 100644 --- a/src/myzod/index.ts +++ b/src/myzod/index.ts @@ -1,26 +1,27 @@ -import { isInput, isNonNullType, isListType, isNamedType, ObjectTypeDefinitionBuilder } from './../graphql'; -import { ValidationSchemaPluginConfig } from '../config'; +import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { - InputValueDefinitionNode, - NameNode, - TypeNode, + EnumTypeDefinitionNode, + FieldDefinitionNode, GraphQLSchema, InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + NameNode, ObjectTypeDefinitionNode, - EnumTypeDefinitionNode, - FieldDefinitionNode, + TypeNode, UnionTypeDefinitionNode, } from 'graphql'; -import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; -import { Visitor } from '../visitor'; +import { ValidationSchemaPluginConfig } from '../config'; import { buildApi, formatDirectiveConfig } from '../directive'; import { SchemaVisitor } from '../types'; +import { Visitor } from '../visitor'; +import { ObjectTypeDefinitionBuilder, isInput, isListType, isNamedType, isNonNullType } from './../graphql'; const importZod = `import * as myzod from 'myzod'`; const anySchema = `definedNonNullAnySchema`; export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchemaPluginConfig): SchemaVisitor => { const importTypes: string[] = []; + const enumDeclarations: string[] = []; return { buildImports: (): string[] => { @@ -37,6 +38,7 @@ export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSche [ new DeclarationBlock({}).export().asKind('const').withName(`${anySchema}`).withContent(`myzod.object({})`) .string, + ...enumDeclarations, ].join('\n'), InputObjectTypeDefinition: { leave: (node: InputObjectTypeDefinitionNode) => { @@ -46,11 +48,22 @@ export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSche const shape = node.fields?.map(field => generateFieldMyZodSchema(config, visitor, field, 2)).join(',\n'); - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): myzod.Type<${name}>`) - .withBlock([indent(`return myzod.object({`), shape, indent('})')].join('\n')).string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: myzod.Type<${name}>`) + .withContent(['myzod.object({', shape, '})'].join('\n')).string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): myzod.Type<${name}>`) + .withBlock([indent(`return myzod.object({`), shape, indent('})')].join('\n')).string; + } }, }, ObjectTypeDefinition: { @@ -61,18 +74,36 @@ export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSche const shape = node.fields?.map(field => generateFieldMyZodSchema(config, visitor, field, 2)).join(',\n'); - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): myzod.Type<${name}>`) - .withBlock( - [ - indent(`return myzod.object({`), - indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), - shape, - indent('})'), - ].join('\n') - ).string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: myzod.Type<${name}>`) + .withContent( + [ + `myzod.object({`, + indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), + shape, + '})', + ].join('\n') + ).string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): myzod.Type<${name}>`) + .withBlock( + [ + indent(`return myzod.object({`), + indent(`__typename: myzod.literal('${node.name.value}').optional(),`, 2), + shape, + indent('})'), + ].join('\n') + ).string; + } }), }, EnumTypeDefinition: { @@ -81,20 +112,22 @@ export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSche const enumname = visitor.convertName(node.name.value); importTypes.push(enumname); // z.enum are basically myzod.literals - if (config.enumsAsTypes) { - return new DeclarationBlock({}) - .export() - .asKind('type') - .withName(`${enumname}Schema`) - .withContent(`myzod.literals(${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')})`) - .string; - } - - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${enumname}Schema`) - .withContent(`myzod.enum(${enumname})`).string; + // hoist enum declarations + enumDeclarations.push( + config.enumsAsTypes + ? new DeclarationBlock({}) + .export() + .asKind('type') + .withName(`${enumname}Schema`) + .withContent( + `myzod.literals(${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')})` + ).string + : new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`myzod.enum(${enumname})`).string + ); }, }, UnionTypeDefinition: { @@ -111,16 +144,31 @@ export const MyZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSche if (typ?.astNode?.kind === 'EnumTypeDefinition') { return `${element}Schema`; } - return `${element}Schema()`; + switch (config.validationSchemaExportType) { + case 'const': + return `${element}Schema`; + case 'function': + default: + return `${element}Schema()`; + } }) .join(', '); const unionElementsCount = node.types?.length ?? 0; - const union = - unionElementsCount > 1 ? indent(`return myzod.union([${unionElements}])`) : indent(`return ${unionElements}`); + const union = unionElementsCount > 1 ? `myzod.union([${unionElements}])` : unionElements; - return new DeclarationBlock({}).export().asKind('function').withName(`${unionName}Schema()`).withBlock(union) - .string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}).export().asKind('const').withName(`${unionName}Schema`).withContent(union) + .string; + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${unionName}Schema()`) + .withBlock(indent(`return ${union}`)).string; + } }, }, }; @@ -197,27 +245,23 @@ const generateNameNodeMyZodSchema = ( ): string => { const converter = visitor.getNameNodeConverter(node); - if (converter?.targetKind === 'InputObjectTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; - } - - if (converter?.targetKind === 'ObjectTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; - } - - if (converter?.targetKind === 'EnumTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema`; - } - - if (converter?.targetKind === 'UnionTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; + switch (converter?.targetKind) { + case 'InputObjectTypeDefinition': + case 'ObjectTypeDefinition': + case 'UnionTypeDefinition': + // using switch-case rather than if-else to allow for future expansion + switch (config.validationSchemaExportType) { + case 'const': + return `${converter.convertName()}Schema`; + case 'function': + default: + return `${converter.convertName()}Schema()`; + } + case 'EnumTypeDefinition': + return `${converter.convertName()}Schema`; + default: + return myzod4Scalar(config, visitor, node.value); } - - return myzod4Scalar(config, visitor, node.value); }; const maybeLazy = (type: TypeNode, schema: string): string => { diff --git a/src/yup/index.ts b/src/yup/index.ts index fa319151..17a12336 100644 --- a/src/yup/index.ts +++ b/src/yup/index.ts @@ -1,25 +1,26 @@ -import { isInput, isNonNullType, isListType, isNamedType, ObjectTypeDefinitionBuilder } from './../graphql'; -import { ValidationSchemaPluginConfig } from '../config'; +import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { - InputValueDefinitionNode, - NameNode, - TypeNode, + EnumTypeDefinitionNode, + FieldDefinitionNode, GraphQLSchema, InputObjectTypeDefinitionNode, - EnumTypeDefinitionNode, + InputValueDefinitionNode, + NameNode, ObjectTypeDefinitionNode, - FieldDefinitionNode, + TypeNode, UnionTypeDefinitionNode, } from 'graphql'; -import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; -import { Visitor } from '../visitor'; +import { ValidationSchemaPluginConfig } from '../config'; import { buildApi, formatDirectiveConfig } from '../directive'; import { SchemaVisitor } from '../types'; +import { Visitor } from '../visitor'; +import { ObjectTypeDefinitionBuilder, isInput, isListType, isNamedType, isNonNullType } from './../graphql'; const importYup = `import * as yup from 'yup'`; export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchemaPluginConfig): SchemaVisitor => { const importTypes: string[] = []; + const enumDeclarations: string[] = []; return { buildImports: (): string[] => { @@ -32,8 +33,10 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema return [importYup]; }, initialEmit: (): string => { - if (!config.withObjectType) return ''; + if (!config.withObjectType) return '\n' + enumDeclarations.join('\n'); return ( + '\n' + + enumDeclarations.join('\n') + '\n' + new DeclarationBlock({}) .asKind('function') @@ -60,11 +63,22 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema }) .join(',\n'); - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): yup.ObjectSchema<${name}>`) - .withBlock([indent(`return yup.object({`), shape, indent('})')].join('\n')).string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: yup.ObjectSchema<${name}>`) + .withContent(['yup.object({', shape, '})'].join('\n')).string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): yup.ObjectSchema<${name}>`) + .withBlock([indent(`return yup.object({`), shape, indent('})')].join('\n')).string; + } }, }, ObjectTypeDefinition: { @@ -80,18 +94,36 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema }) .join(',\n'); - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): yup.ObjectSchema<${name}>`) - .withBlock( - [ - indent(`return yup.object({`), - indent(`__typename: yup.string<'${node.name.value}'>().optional(),`, 2), - shape, - indent('})'), - ].join('\n') - ).string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: yup.ObjectSchema<${name}>`) + .withContent( + [ + `yup.object({`, + indent(`__typename: yup.string<'${node.name.value}'>().optional(),`, 2), + shape, + '})', + ].join('\n') + ).string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): yup.ObjectSchema<${name}>`) + .withBlock( + [ + indent(`return yup.object({`), + indent(`__typename: yup.string<'${node.name.value}'>().optional(),`, 2), + shape, + indent('})'), + ].join('\n') + ).string; + } }), }, EnumTypeDefinition: { @@ -100,30 +132,35 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema const enumname = visitor.convertName(node.name.value); importTypes.push(enumname); + // hoise enum declarations if (config.enumsAsTypes) { const enums = node.values?.map(enumOption => `'${enumOption.name.value}'`); - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${enumname}Schema`) - .withContent(`yup.string().oneOf([${enums?.join(', ')}]).defined()`).string; + enumDeclarations.push( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`yup.string().oneOf([${enums?.join(', ')}]).defined()`).string + ); + } else { + const values = node.values + ?.map( + enumOption => + `${enumname}.${visitor.convertName(enumOption.name, { + useTypesPrefix: false, + transformUnderscore: true, + })}` + ) + .join(', '); + enumDeclarations.push( + new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`yup.string<${enumname}>().oneOf([${values}]).defined()`).string + ); } - - const values = node.values - ?.map( - enumOption => - `${enumname}.${visitor.convertName(enumOption.name, { - useTypesPrefix: false, - transformUnderscore: true, - })}` - ) - .join(', '); - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${enumname}Schema`) - .withContent(`yup.string<${enumname}>().oneOf([${values}]).defined()`).string; }, }, UnionTypeDefinition: { @@ -141,16 +178,31 @@ export const YupSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema if (typ?.astNode?.kind === 'EnumTypeDefinition') { return `${element}Schema`; } - return `${element}Schema()`; + switch (config.validationSchemaExportType) { + case 'const': + return `${element}Schema`; + case 'function': + default: + return `${element}Schema()`; + } }) .join(', '); - const union = indent(`return union<${unionName}>(${unionElements})`); - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${unionName}Schema(): yup.MixedSchema<${unionName}>`) - .withBlock(union).string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${unionName}Schema: yup.MixedSchema<${unionName}>`) + .withContent(`union<${unionName}>(${unionElements})`).string; + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${unionName}Schema(): yup.MixedSchema<${unionName}>`) + .withBlock(indent(`return union<${unionName}>(${unionElements})`)).string; + } }, }, // ScalarTypeDefinition: (node) => { @@ -228,28 +280,23 @@ const generateFieldTypeYupSchema = ( const generateNameNodeYupSchema = (config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode): string => { const converter = visitor.getNameNodeConverter(node); - if (converter?.targetKind === 'InputObjectTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; - } - - if (converter?.targetKind === 'ObjectTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; - } - - if (converter?.targetKind === 'EnumTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema`; - } - - if (converter?.targetKind === 'UnionTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; + switch (converter?.targetKind) { + case 'InputObjectTypeDefinition': + case 'ObjectTypeDefinition': + case 'UnionTypeDefinition': + // using switch-case rather than if-else to allow for future expansion + switch (config.validationSchemaExportType) { + case 'const': + return `${converter.convertName()}Schema`; + case 'function': + default: + return `${converter.convertName()}Schema()`; + } + case 'EnumTypeDefinition': + return `${converter.convertName()}Schema`; + default: + return yup4Scalar(config, visitor, node.value); } - - const primitive = yup4Scalar(config, visitor, node.value); - return primitive; }; const maybeLazy = (type: TypeNode, schema: string): string => { diff --git a/src/zod/index.ts b/src/zod/index.ts index 7fabe75d..efd3d060 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -1,26 +1,27 @@ -import { isInput, isNonNullType, isListType, isNamedType, ObjectTypeDefinitionBuilder } from './../graphql'; -import { ValidationSchemaPluginConfig } from '../config'; +import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; import { - InputValueDefinitionNode, - NameNode, - TypeNode, + EnumTypeDefinitionNode, + FieldDefinitionNode, GraphQLSchema, InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + NameNode, ObjectTypeDefinitionNode, - EnumTypeDefinitionNode, + TypeNode, UnionTypeDefinitionNode, - FieldDefinitionNode, } from 'graphql'; -import { DeclarationBlock, indent } from '@graphql-codegen/visitor-plugin-common'; -import { Visitor } from '../visitor'; +import { ValidationSchemaPluginConfig } from '../config'; import { buildApi, formatDirectiveConfig } from '../directive'; import { SchemaVisitor } from '../types'; +import { Visitor } from '../visitor'; +import { ObjectTypeDefinitionBuilder, isInput, isListType, isNamedType, isNonNullType } from './../graphql'; const importZod = `import { z } from 'zod'`; const anySchema = `definedNonNullAnySchema`; export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchemaPluginConfig): SchemaVisitor => { const importTypes: string[] = []; + const enumDeclarations: string[] = []; return { buildImports: (): string[] => { @@ -53,6 +54,7 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema .asKind('const') .withName(`${anySchema}`) .withContent(`z.any().refine((v) => isDefinedNonNullAny(v))`).string, + ...enumDeclarations, ].join('\n'), InputObjectTypeDefinition: { leave: (node: InputObjectTypeDefinitionNode) => { @@ -62,11 +64,22 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema const shape = node.fields?.map(field => generateFieldZodSchema(config, visitor, field, 2)).join(',\n'); - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): z.ZodObject>`) - .withBlock([indent(`return z.object>({`), shape, indent('})')].join('\n')).string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent(['z.object({', shape, '})'].join('\n')).string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock([indent(`return z.object({`), shape, indent('})')].join('\n')).string; + } }, }, ObjectTypeDefinition: { @@ -77,18 +90,33 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema const shape = node.fields?.map(field => generateFieldZodSchema(config, visitor, field, 2)).join(',\n'); - return new DeclarationBlock({}) - .export() - .asKind('function') - .withName(`${name}Schema(): z.ZodObject>`) - .withBlock( - [ - indent(`return z.object>({`), - indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), - shape, - indent('})'), - ].join('\n') - ).string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${name}Schema: z.ZodObject>`) + .withContent( + [`z.object({`, indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), shape, '})'].join( + '\n' + ) + ).string; + + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${name}Schema(): z.ZodObject>`) + .withBlock( + [ + indent(`return z.object({`), + indent(`__typename: z.literal('${node.name.value}').optional(),`, 2), + shape, + indent('})'), + ].join('\n') + ).string; + } }), }, EnumTypeDefinition: { @@ -97,19 +125,21 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema const enumname = visitor.convertName(node.name.value); importTypes.push(enumname); - if (config.enumsAsTypes) { - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${enumname}Schema`) - .withContent(`z.enum([${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')}])`).string; - } - - return new DeclarationBlock({}) - .export() - .asKind('const') - .withName(`${enumname}Schema`) - .withContent(`z.nativeEnum(${enumname})`).string; + // hoist enum declarations + enumDeclarations.push( + config.enumsAsTypes + ? new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`z.enum([${node.values?.map(enumOption => `'${enumOption.name.value}'`).join(', ')}])`) + .string + : new DeclarationBlock({}) + .export() + .asKind('const') + .withName(`${enumname}Schema`) + .withContent(`z.nativeEnum(${enumname})`).string + ); }, }, UnionTypeDefinition: { @@ -124,16 +154,31 @@ export const ZodSchemaVisitor = (schema: GraphQLSchema, config: ValidationSchema if (typ?.astNode?.kind === 'EnumTypeDefinition') { return `${element}Schema`; } - return `${element}Schema()`; + switch (config.validationSchemaExportType) { + case 'const': + return `${element}Schema`; + case 'function': + default: + return `${element}Schema()`; + } }) .join(', '); const unionElementsCount = node.types.length ?? 0; - const union = - unionElementsCount > 1 ? indent(`return z.union([${unionElements}])`) : indent(`return ${unionElements}`); + const union = unionElementsCount > 1 ? `z.union([${unionElements}])` : unionElements; - return new DeclarationBlock({}).export().asKind('function').withName(`${unionName}Schema()`).withBlock(union) - .string; + switch (config.validationSchemaExportType) { + case 'const': + return new DeclarationBlock({}).export().asKind('const').withName(`${unionName}Schema`).withContent(union) + .string; + case 'function': + default: + return new DeclarationBlock({}) + .export() + .asKind('function') + .withName(`${unionName}Schema()`) + .withBlock(indent(`return ${union}`)).string; + } }, }, }; @@ -206,27 +251,23 @@ const applyDirectives = ( const generateNameNodeZodSchema = (config: ValidationSchemaPluginConfig, visitor: Visitor, node: NameNode): string => { const converter = visitor.getNameNodeConverter(node); - if (converter?.targetKind === 'InputObjectTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; - } - - if (converter?.targetKind === 'ObjectTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; - } - - if (converter?.targetKind === 'EnumTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema`; - } - - if (converter?.targetKind === 'UnionTypeDefinition') { - const name = converter.convertName(); - return `${name}Schema()`; + switch (converter?.targetKind) { + case 'InputObjectTypeDefinition': + case 'ObjectTypeDefinition': + case 'UnionTypeDefinition': + // using switch-case rather than if-else to allow for future expansion + switch (config.validationSchemaExportType) { + case 'const': + return `${converter.convertName()}Schema`; + case 'function': + default: + return `${converter.convertName()}Schema()`; + } + case 'EnumTypeDefinition': + return `${converter.convertName()}Schema`; + default: + return zod4Scalar(config, visitor, node.value); } - - return zod4Scalar(config, visitor, node.value); }; const maybeLazy = (type: TypeNode, schema: string): string => { diff --git a/tests/graphql.spec.ts b/tests/graphql.spec.ts index 634058d1..75c43dab 100644 --- a/tests/graphql.spec.ts +++ b/tests/graphql.spec.ts @@ -1,5 +1,7 @@ -import { Kind, ObjectTypeDefinitionNode } from 'graphql'; -import { ObjectTypeDefinitionBuilder } from '../src/graphql'; +import { Kind, ObjectTypeDefinitionNode, buildSchema, parse, print } from 'graphql'; +import { ObjectTypeDefinitionBuilder, topsort, topologicalSortAST } from '../src/graphql'; +import { Graph } from 'graphlib'; +import dedent from 'ts-dedent'; describe('graphql', () => { describe('ObjectTypeDefinitionBuilder', () => { @@ -44,3 +46,194 @@ describe('graphql', () => { }); }); }); + +describe('topsort', () => { + it('should correctly sort nodes based on their dependencies', () => { + const g = new Graph(); + + // Setting up the graph + g.setNode('A'); + g.setNode('B'); + g.setNode('C'); + g.setEdge('A', 'B'); + g.setEdge('B', 'C'); + + const sortedNodes = topsort(g); + expect(sortedNodes).toEqual(['C', 'B', 'A']); + }); + + it('should correctly handle graph with no edges', () => { + const g = new Graph(); + + // Setting up the graph + g.setNode('A'); + g.setNode('B'); + g.setNode('C'); + + const sortedNodes = topsort(g); + const isCorrectOrder = ['A', 'B', 'C'].every(node => sortedNodes.includes(node)); + expect(isCorrectOrder).toBe(true); + }); + + it('should correctly handle an empty graph', () => { + const g = new Graph(); + + const sortedNodes = topsort(g); + expect(sortedNodes).toEqual([]); + }); + + it('should correctly handle graph with multiple dependencies', () => { + const g = new Graph(); + + // Setting up the graph + g.setNode('A'); + g.setNode('B'); + g.setNode('C'); + g.setNode('D'); + g.setEdge('A', 'B'); + g.setEdge('A', 'C'); + g.setEdge('B', 'D'); + g.setEdge('C', 'D'); + + const sortedNodes = topsort(g); + expect(sortedNodes).toEqual(['D', 'C', 'B', 'A']); + }); +}); + +describe('topologicalSortAST', () => { + const getSortedSchema = (schema: string): string => { + const ast = parse(schema); + const sortedAst = topologicalSortAST(buildSchema(schema), ast); + return print(sortedAst); + }; + + it('should correctly sort nodes based on their input type dependencies', () => { + const schema = /* GraphQL */ ` + input A { + b: B + } + + input B { + c: C + } + + input C { + d: String + } + `; + + const sortedSchema = getSortedSchema(schema); + + const expectedSortedSchema = dedent/* GraphQL */ ` + input C { + d: String + } + + input B { + c: C + } + + input A { + b: B + } + `; + + expect(sortedSchema).toBe(expectedSortedSchema); + }); + + it('should correctly sort nodes based on their objet type dependencies', () => { + const schema = /* GraphQL */ ` + type D { + e: UserKind + } + + union UserKind = A | B + + type A { + b: B + } + + type B { + c: C + } + + type C { + d: String + } + `; + + const sortedSchema = getSortedSchema(schema); + + const expectedSortedSchema = dedent/* GraphQL */ ` + type C { + d: String + } + + type B { + c: C + } + + type A { + b: B + } + + union UserKind = A | B + + type D { + e: UserKind + } + `; + + expect(sortedSchema).toBe(expectedSortedSchema); + }); + + it('should correctly handle schema with circular dependencies', () => { + const schema = /* GraphQL */ ` + input A { + b: B + } + + input B { + a: A + } + `; + const sortedSchema = getSortedSchema(schema); + + const expectedSortedSchema = dedent/* GraphQL */ ` + input A { + b: B + } + + input B { + a: A + } + `; + + expect(sortedSchema).toBe(expectedSortedSchema); + }); + + it('should correctly handle schema with self circular dependencies', () => { + const schema = /* GraphQL */ ` + input A { + a: A + } + + input B { + b: B + } + `; + const sortedSchema = getSortedSchema(schema); + + const expectedSortedSchema = dedent/* GraphQL */ ` + input A { + a: A + } + + input B { + b: B + } + `; + + expect(sortedSchema).toBe(expectedSortedSchema); + }); +}); diff --git a/tests/myzod.spec.ts b/tests/myzod.spec.ts index c59bfab4..a157cd09 100644 --- a/tests/myzod.spec.ts +++ b/tests/myzod.spec.ts @@ -733,6 +733,43 @@ describe('myzod', () => { expect(result.content).toContain(wantContain); } }); + + it('generate union types with single element, export as const', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Square { + size: Int + } + type Circle { + radius: Int + } + union Shape = Circle | Square + + type Geometry { + shape: Shape + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'myzod', + withObjectType: true, + validationSchemaExportType: 'const', + }, + {} + ); + + const wantContains = [ + 'export const GeometrySchema: myzod.Type = myzod.object({', + "__typename: myzod.literal('Geometry').optional(),", + 'shape: ShapeSchema.optional().nullable()', + '}', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); }); it('properly generates custom directive values', async () => { @@ -768,4 +805,88 @@ describe('myzod', () => { expect(result.content).toContain(wantContain); } }); + + it('exports as const instead of func', async () => { + const schema = buildSchema(/* GraphQL */ ` + input Say { + phrase: String! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + validationSchemaExportType: 'const', + }, + {} + ); + expect(result.content).toContain('export const SaySchema: myzod.Type = myzod.object({'); + }); + + it('generate both input & type, export as const', async () => { + const schema = buildSchema(/* GraphQL */ ` + scalar Date + scalar Email + input UserCreateInput { + name: String! + date: Date! + email: Email! + } + type User { + id: ID! + name: String + age: Int + email: Email + isMember: Boolean + createdAt: Date! + } + type Mutation { + _empty: String + } + type Query { + _empty: String + } + type Subscription { + _empty: String + } + `); + const result = await plugin( + schema, + [], + { + schema: 'myzod', + withObjectType: true, + scalarSchemas: { + Date: 'myzod.date()', + Email: 'myzod.string().email()', + }, + validationSchemaExportType: 'const', + }, + {} + ); + const wantContains = [ + // User Create Input + 'export const UserCreateInputSchema: myzod.Type = myzod.object({', + 'name: myzod.string(),', + 'date: myzod.date(),', + 'email: myzod.string().email()', + // User + 'export const UserSchema: myzod.Type = myzod.object({', + "__typename: myzod.literal('User').optional(),", + 'id: myzod.string(),', + 'name: myzod.string().optional().nullable(),', + 'age: myzod.number().optional().nullable(),', + 'email: myzod.string().email().optional().nullable(),', + 'isMember: myzod.boolean().optional().nullable(),', + 'createdAt: myzod.date()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } + }); }); diff --git a/tests/yup.spec.ts b/tests/yup.spec.ts index 4670fb76..c8e68c6a 100644 --- a/tests/yup.spec.ts +++ b/tests/yup.spec.ts @@ -647,6 +647,43 @@ describe('yup', () => { expect(result.content).toContain(wantContain); } }); + + it('generate union types with single element, export as const', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Square { + size: Int + } + type Circle { + radius: Int + } + union Shape = Circle | Square + + type Geometry { + shape: Shape + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'yup', + withObjectType: true, + validationSchemaExportType: 'const', + }, + {} + ); + + const wantContains = [ + 'export const GeometrySchema: yup.ObjectSchema = yup.object({', + "__typename: yup.string<'Geometry'>().optional(),", + 'shape: ShapeSchema.nullable().optional()', + '})', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); }); it('properly generates custom directive values', async () => { @@ -682,4 +719,88 @@ describe('yup', () => { expect(result.content).toContain(wantContain); } }); + + it('exports as const instead of func', async () => { + const schema = buildSchema(/* GraphQL */ ` + input Say { + phrase: String! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'yup', + validationSchemaExportType: 'const', + }, + {} + ); + expect(result.content).toContain('export const SaySchema: yup.ObjectSchema = yup.object({'); + }); + + it('generate both input & type, export as const', async () => { + const schema = buildSchema(/* GraphQL */ ` + scalar Date + scalar Email + input UserCreateInput { + name: String! + date: Date! + email: Email! + } + type User { + id: ID! + name: String + age: Int + email: Email + isMember: Boolean + createdAt: Date! + } + type Mutation { + _empty: String + } + type Query { + _empty: String + } + type Subscription { + _empty: String + } + `); + const result = await plugin( + schema, + [], + { + schema: 'yup', + withObjectType: true, + scalarSchemas: { + Date: 'yup.date()', + Email: 'yup.string().email()', + }, + validationSchemaExportType: 'const', + }, + {} + ); + const wantContains = [ + // User Create Input + 'export const UserCreateInputSchema: yup.ObjectSchema = yup.object({', + 'name: yup.string().defined().nonNullable(),', + 'date: yup.date().defined().nonNullable(),', + 'email: yup.string().email().defined().nonNullable()', + // User + 'export const UserSchema: yup.ObjectSchema = yup.object({', + "__typename: yup.string<'User'>().optional(),", + 'id: yup.string().defined().nonNullable(),', + 'name: yup.string().defined().nullable().optional(),', + 'age: yup.number().defined().nullable().optional(),', + 'email: yup.string().email().defined().nullable().optional(),', + 'isMember: yup.boolean().defined().nullable().optional(),', + 'createdAt: yup.date().defined().nonNullable()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } + }); }); diff --git a/tests/zod.spec.ts b/tests/zod.spec.ts index ae7f869d..8ba1c402 100644 --- a/tests/zod.spec.ts +++ b/tests/zod.spec.ts @@ -1,6 +1,5 @@ import { buildSchema } from 'graphql'; import { plugin } from '../src/index'; -import { ScalarsMap } from '@graphql-codegen/visitor-plugin-common'; describe('zod', () => { test.each([ @@ -802,6 +801,42 @@ describe('zod', () => { expect(result.content).toContain(wantContain); } }); + + it('generate union types with single element, export as const', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Square { + size: Int + } + type Circle { + radius: Int + } + union Shape = Circle | Square + + type Geometry { + shape: Shape + } + `); + + const result = await plugin( + schema, + [], + { + schema: 'zod', + withObjectType: true, + validationSchemaExportType: 'const', + }, + {} + ); + + const wantContains = [ + 'export const GeometrySchema: z.ZodObject> = z.object({', + "__typename: z.literal('Geometry').optional(),", + 'shape: ShapeSchema.nullish()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + }); }); it('properly generates custom directive values', async () => { @@ -837,4 +872,88 @@ describe('zod', () => { expect(result.content).toContain(wantContain); } }); + + it('exports as const instead of func', async () => { + const schema = buildSchema(/* GraphQL */ ` + input Say { + phrase: String! + } + `); + const result = await plugin( + schema, + [], + { + schema: 'zod', + validationSchemaExportType: 'const', + }, + {} + ); + expect(result.content).toContain('export const SaySchema: z.ZodObject> = z.object({'); + }); + + it('generate both input & type, export as const', async () => { + const schema = buildSchema(/* GraphQL */ ` + scalar Date + scalar Email + input UserCreateInput { + name: String! + date: Date! + email: Email! + } + type User { + id: ID! + name: String + age: Int + email: Email + isMember: Boolean + createdAt: Date! + } + type Mutation { + _empty: String + } + type Query { + _empty: String + } + type Subscription { + _empty: String + } + `); + const result = await plugin( + schema, + [], + { + schema: 'zod', + withObjectType: true, + scalarSchemas: { + Date: 'z.date()', + Email: 'z.string().email()', + }, + validationSchemaExportType: 'const', + }, + {} + ); + const wantContains = [ + // User Create Input + 'export const UserCreateInputSchema: z.ZodObject> = z.object({', + 'name: z.string(),', + 'date: z.date(),', + 'email: z.string().email()', + // User + 'export const UserSchema: z.ZodObject> = z.object({', + "__typename: z.literal('User').optional()", + 'id: z.string(),', + 'name: z.string().nullish(),', + 'age: z.number().nullish(),', + 'isMember: z.boolean().nullish(),', + 'email: z.string().email().nullish(),', + 'createdAt: z.date()', + ]; + for (const wantContain of wantContains) { + expect(result.content).toContain(wantContain); + } + + for (const wantNotContain of ['Query', 'Mutation', 'Subscription']) { + expect(result.content).not.toContain(wantNotContain); + } + }); }); diff --git a/yarn.lock b/yarn.lock index 097e880b..c3e028b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1757,6 +1757,11 @@ dependencies: "@types/node" "*" +"@types/graphlib@^2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@types/graphlib/-/graphlib-2.1.8.tgz#9edd607e4b863a33b8b78cb08385c0be6896008a" + integrity sha512-8nbbyD3zABRA9ePoBgAl2ym8cIwKQXTfv1gaIRTdY99yEOCaHfmjBeRp+BIemS8NtOqoWK7mfzWxjNrxLK3T5w== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -3199,6 +3204,13 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +graphlib@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" + integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A== + dependencies: + lodash "^4.17.15" + graphql-config@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/graphql-config/-/graphql-config-5.0.2.tgz#7e962f94ccddcc2ee0aa71d75cf4491ec5092bdb" @@ -4234,7 +4246,7 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.20, lodash@^4.17.21, lodash@~4.17.0: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@~4.17.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -5356,13 +5368,18 @@ to-regex-range@^5.0.1: toposort@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" - integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +ts-dedent@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" + integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== + ts-jest@29.1.0: version "29.1.0" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.0.tgz#4a9db4104a49b76d2b368ea775b6c9535c603891"