diff --git a/.changeset/flat-buckets-make.md b/.changeset/flat-buckets-make.md new file mode 100644 index 000000000..2804eccd5 --- /dev/null +++ b/.changeset/flat-buckets-make.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/fastify-generators': patch +--- + +Use Zod schema defined in mutations instead of restrictObjectNulls to allow for cleaner mutations and validation diff --git a/.changeset/twelve-readers-fail.md b/.changeset/twelve-readers-fail.md new file mode 100644 index 000000000..4a3c25dd6 --- /dev/null +++ b/.changeset/twelve-readers-fail.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/fastify-generators': patch +--- + +Add support for validation plugin in Pothos diff --git a/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json b/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json index b91ccc8a8..d00f98fee 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json +++ b/examples/blog-with-auth/apps/backend/baseplate/file-id-map.json @@ -5,8 +5,6 @@ "@baseplate-dev/core-generators#node/node:package-json": "package.json", "@baseplate-dev/core-generators#node/prettier:prettier-config": ".prettierrc", "@baseplate-dev/core-generators#node/prettier:prettier-ignore": ".prettierignore", - "@baseplate-dev/core-generators#node/ts-utils:normalize-types": "src/utils/normalize-types.ts", - "@baseplate-dev/core-generators#node/ts-utils:nulls": "src/utils/nulls.ts", "@baseplate-dev/core-generators#node/ts-utils:string": "src/utils/string.ts", "@baseplate-dev/core-generators#node/typescript:tsconfig": "tsconfig.json", "@baseplate-dev/core-generators#node/vitest:global-setup": "src/tests/scripts/global-setup.ts", diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/package.json b/examples/blog-with-auth/apps/backend/baseplate/generated/package.json index f83614140..973572322 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/package.json +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/package.json @@ -43,6 +43,7 @@ "@pothos/plugin-relay": "4.6.2", "@pothos/plugin-simple-objects": "4.1.3", "@pothos/plugin-tracing": "1.1.0", + "@pothos/plugin-validation": "4.2.0", "@pothos/tracing-sentry": "1.1.1", "@prisma/adapter-pg": "6.17.1", "@prisma/client": "6.17.1", diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts index 679d3b926..a91835609 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/schema/user.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { createUser, @@ -10,13 +9,15 @@ import { } from '../services/user.data-service.js'; import { userObjectType } from './user.object-type.js'; -const createUserDataInputType = builder.inputType('CreateUserData', { - fields: (t) => ({ - email: t.string(), - name: t.string(), - emailVerified: t.boolean(), - }), -}); +const createUserDataInputType = builder + .inputType('CreateUserData', { + fields: (t) => ({ + email: t.string(), + name: t.string(), + emailVerified: t.boolean(), + }), + }) + .validate(createUser.$dataSchema); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ @@ -27,22 +28,25 @@ builder.mutationField('createUser', (t) => authorize: ['admin'], resolve: async (root, { input: { data } }, context, info) => { const user = await createUser({ - data: restrictObjectNulls(data, ['emailVerified']), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, }), ); -const updateUserDataInputType = builder.inputType('UpdateUserData', { - fields: (t) => ({ - email: t.string(), - name: t.string(), - emailVerified: t.boolean(), - }), -}); +const updateUserDataInputType = builder + .inputType('UpdateUserData', { + fields: (t) => ({ + email: t.string(), + name: t.string(), + emailVerified: t.boolean(), + }), + }) + .validate(updateUser.$dataSchema); builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ @@ -55,9 +59,10 @@ builder.mutationField('updateUser', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ where: { id }, - data: restrictObjectNulls(data, ['emailVerified']), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts index 6020c5448..84514f678 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts @@ -11,6 +11,7 @@ import type { PothosFieldWithInputPayloadPlugin } from './index.js'; import type { MutationWithInputPayloadOptions, OutputShapeFromFields, + PayloadFieldRef, } from './types.js'; declare global { @@ -56,10 +57,7 @@ declare global { payload: RootFieldBuilder; fieldWithInputPayload: < InputFields extends InputFieldMap, - PayloadFields extends Record< - string, - FieldRef - >, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, Args extends InputFieldMap = Record, @@ -79,7 +77,7 @@ declare global { ShapeFromTypeParam< Types, ObjectRef>, - false + Types['DefaultFieldNullability'] > >; } diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts index 1a355a40b..12ae4225e 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts @@ -8,6 +8,8 @@ import { import { capitalizeString } from '@src/utils/string.js'; +import type { PayloadFieldRef } from './types.js'; + const rootBuilderProto = RootFieldBuilder.prototype as PothosSchemaTypes.RootFieldBuilder< SchemaTypes, @@ -27,11 +29,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ // expose all fields of payload by default const payloadFields = (): Record< string, - FieldRef + PayloadFieldRef > => { for (const key of Object.keys(payload)) { payload[key].onFirstUse((cfg) => { - if (cfg.kind === 'Object') { + if (cfg.kind === 'Object' && !cfg.resolve) { cfg.resolve = (parent) => (parent as Record)[key] as Readonly; } @@ -56,9 +58,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ type: payloadRef, nullable: false, ...fieldOptions, - } as never); + } as never) as FieldRef; - fieldRef.onFirstUse((config) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fieldRef.onFirstUse((config: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const capitalizedName = capitalizeString(config.name); const inputName = `${capitalizedName}Input`; const payloadName = `${capitalizedName}Payload`; @@ -66,6 +70,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ if (inputRef) { inputRef.name = inputName; this.builder.inputType(inputRef, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Input type for ${config.name} mutation`, fields: () => input, }); @@ -74,6 +79,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ payloadRef.name = payloadName; this.builder.objectType(payloadRef, { name: payloadName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Payload type for ${config.name} mutation`, fields: payloadFields, }); diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts index bf2e7d518..90b3586cd 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts @@ -12,6 +12,11 @@ import type { SchemaTypes, } from '@pothos/core'; +export type PayloadFieldRef = Omit< + FieldRef, + 'validate' +>; + export type OutputShapeFromFields = NullableToOptional<{ [K in keyof Fields]: Fields[K] extends GenericFieldRef ? T : never; @@ -23,7 +28,7 @@ export type MutationWithInputPayloadOptions< Kind extends FieldKind, Args extends InputFieldMap, InputFields extends InputFieldMap, - PayloadFields extends Record>, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, > = Omit< diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts index 5e1d36181..5ecb1761f 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts @@ -3,6 +3,7 @@ import PrismaPlugin from '@pothos/plugin-prisma'; import RelayPlugin from '@pothos/plugin-relay'; import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'; import TracingPlugin, { isRootField } from '@pothos/plugin-tracing'; +import ValidationPlugin from '@pothos/plugin-validation'; import { createSentryWrapper } from '@pothos/tracing-sentry'; import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; @@ -57,6 +58,7 @@ export const builder = new SchemaBuilder<{ pothosStripQueryMutationPlugin, RelayPlugin, SimpleObjectsPlugin, + ValidationPlugin, ], prisma: { client: prisma, diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts index b571281de..9c584252c 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts @@ -1,5 +1,7 @@ import type { Result } from '@prisma/client/runtime/client'; +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import { prisma } from '@src/services/prisma.js'; @@ -20,6 +22,7 @@ import type { InferFieldsOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, OperationHooks, PrismaTransaction, @@ -205,6 +208,49 @@ export async function transformFields< return { data: transformedData, hooks }; } +/** + * ========================================= + * Schema Generation Utilities + * ========================================= + */ + +/** + * Generates a Zod schema for create operations from field definitions. + * + * Extracts the Zod schema from each field definition and combines them + * into a single object schema. This schema can be used for validation + * in GraphQL resolvers, REST endpoints, tRPC procedures, or OpenAPI documentation. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to extract schemas from + * @returns Zod object schema with all fields required + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }; + * + * const schema = generateCreateSchema(fields); + * // schema is z.object({ name: z.string(), email: z.string().email() }) + * + * // Use for validation + * const validated = schema.parse({ name: 'John', email: 'john@example.com' }); + * ``` + */ +export function generateCreateSchema< + TFields extends Record, +>(fields: TFields): InferInputSchema { + const shape = Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.schema]), + ) as { + [K in keyof TFields]: TFields[K]['schema']; + }; + + return z.object(shape) as InferInputSchema; +} + /** * ========================================= * Create Operation @@ -273,6 +319,9 @@ export interface CreateOperationConfig< > >; + /** + * Optional hooks for the operation + */ hooks?: OperationHooks>; } @@ -294,8 +343,22 @@ export interface CreateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type CreateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: CreateOperationInput, +) => Promise>) & { + $dataSchema: InferInputSchema; +}; + /** * Defines a type-safe create operation for a Prisma model. * @@ -349,14 +412,17 @@ export function defineCreateOperation< >, >( config: CreateOperationConfig, -): >( - input: CreateOperationInput, -) => Promise> { - return async >({ +): CreateOperationFunction { + const dataSchema = generateCreateSchema(config.fields); + + const createOperation = async >({ data, query, context, - }: CreateOperationInput) => { + skipValidation, + }: CreateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -364,6 +430,9 @@ export function defineCreateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation ? data : dataSchema.parse(data); + const baseOperationContext: OperationContext< GetPayload, { hasResult: false } @@ -376,20 +445,20 @@ export function defineCreateOperation< // Authorization if (config.authorize) { - await config.authorize(data, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } // Step 1: Transform fields (OUTSIDE TRANSACTION) const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(config.fields, data, { + transformFields(config.fields, validatedData, { operation: 'create', serviceContext: context, allowOptionalFields: false, loadExisting: () => Promise.resolve(undefined), }), config.prepareComputedFields - ? config.prepareComputedFields(data, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -451,6 +520,8 @@ export function defineCreateOperation< return result as GetPayload; }); }; + createOperation.$dataSchema = dataSchema; + return createOperation; } /** @@ -549,8 +620,23 @@ export interface UpdateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type UpdateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: UpdateOperationInput, +) => Promise>) & { + $dataSchema: z.ZodObject<{ + [k in keyof TFields]: z.ZodOptional; + }>; +}; /** * Defines a type-safe update operation for a Prisma model. * @@ -605,15 +691,18 @@ export function defineUpdateOperation< >, >( config: UpdateOperationConfig, -): >( - input: UpdateOperationInput, -) => Promise> { - return async >({ +): UpdateOperationFunction { + const dataSchema = generateCreateSchema(config.fields).partial(); + + const updateOperation = async >({ where, data: inputData, query, context, - }: UpdateOperationInput) => { + skipValidation, + }: UpdateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -621,6 +710,11 @@ export function defineUpdateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation + ? inputData + : dataSchema.parse(inputData); + let existingItem: GetPayload | undefined; const delegate = makeGenericPrismaDelegate(prisma, config.model); @@ -644,27 +738,31 @@ export function defineUpdateOperation< }; // Authorization if (config.authorize) { - await config.authorize(inputData, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } - // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Step 1: Transform fields (outside transaction) // Only transform fields provided in input const fieldsToTransform = Object.fromEntries( - Object.entries(config.fields).filter(([key]) => key in inputData), + Object.entries(config.fields).filter(([key]) => key in validatedData), ) as TFields; const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(fieldsToTransform, inputData as InferInput, { - operation: 'update', - serviceContext: context, - allowOptionalFields: true, - loadExisting: baseOperationContext.loadExisting as () => Promise< - Record - >, - }), + transformFields( + fieldsToTransform, + validatedData as InferInput, + { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }, + ), config.prepareComputedFields - ? config.prepareComputedFields(inputData, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -728,6 +826,8 @@ export function defineUpdateOperation< return result as GetPayload; }); }; + updateOperation.$dataSchema = generateCreateSchema(config.fields).partial(); + return updateOperation; } /** diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts index ab87233ae..f12c65e00 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts @@ -17,47 +17,75 @@ import type { InferFieldsCreateOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, TransactionalOperationContext, } from './types.js'; -import { invokeHooks, transformFields } from './define-operations.js'; +import { + generateCreateSchema, + invokeHooks, + transformFields, +} from './define-operations.js'; import { makeGenericPrismaDelegate } from './prisma-utils.js'; /** - * Create a simple scalar field with validation only + * Create a simple scalar field with validation and optional transformation * * This helper creates a field definition that validates input using a Zod schema. - * The validated value is passed through unchanged to the transform step. + * Optionally, you can provide a transform function to convert the validated value + * into a different type for Prisma operations. * * For relation fields (e.g., `userId`), use this helper to validate the ID, * then use relation helpers in the transform step to create Prisma connect/disconnect objects. * + * @template TSchema - The Zod schema type for validation + * @template TTransformed - The output type after transformation (defaults to schema output) * @param schema - Zod schema for validation + * @param options - Optional configuration + * @param options.transform - Function to transform the validated value * @returns Field definition * * @example * ```typescript + * // Simple validation * const fields = { * title: scalarField(z.string()), * ownerId: scalarField(z.string()), // Validated as string * }; * - * // In transform, convert IDs to relations: - * transform: (data) => ({ - * title: data.title, - * owner: relation.required(data.ownerId), - * }) + * // With transformation + * const fields = { + * email: scalarField( + * z.string().email(), + * { transform: (email) => email.toLowerCase() } + * ), + * createdAt: scalarField( + * z.string().datetime(), + * { transform: (dateStr) => new Date(dateStr) } + * ), + * }; * ``` */ -export function scalarField( +export function scalarField< + TSchema extends z.ZodSchema, + TTransformed = z.output, +>( schema: TSchema, -): FieldDefinition, z.output, z.output> { + options?: { + transform?: (value: z.output) => TTransformed; + }, +): FieldDefinition { return { + schema, processInput: (value) => { - const validated = schema.parse(value) as z.output; + // Apply transform if provided + const transformed = options?.transform + ? options.transform(value) + : (value as TTransformed); + return { - data: { create: validated, update: validated }, + data: { create: transformed, update: transformed }, }; }, }; @@ -190,15 +218,13 @@ export interface NestedOneToOneFieldConfig< * This helper creates a field definition for managing one-to-one nested relationships. * It handles nested field validation, transformation, and supports both create and update operations. * - * For create operations: - * - Returns nested create data if input is provided - * - Returns undefined if input is not provided + * The nested entity is created/updated via afterExecute hooks, allowing it to reference + * the parent entity after it has been created. * - * For update operations: - * - Returns upsert if input has a unique identifier (via getWhereUnique) - * - Returns create if input doesn't have a unique identifier - * - Deletes the relation if input is null (requires deleteRelation) - * - Returns undefined if input is not provided (no change) + * Behavior: + * - **Provided value**: Upserts the nested entity (creates if new, updates if exists) + * - **null**: Deletes the nested entity (update only) + * - **undefined**: No change to nested entity * * @param config - Configuration object * @returns Field definition @@ -207,18 +233,24 @@ export interface NestedOneToOneFieldConfig< * ```typescript * const fields = { * userProfile: nestedOneToOneField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userProfile', + * relationName: 'user', * fields: { * bio: scalarField(z.string()), * avatar: fileField(avatarFileCategory), * }, + * getWhereUnique: (parent) => ({ userId: parent.id }), * buildData: (data) => ({ - * bio: data.bio, - * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * create: { + * bio: data.create.bio, + * avatar: data.create.avatar ? { connect: { id: data.create.avatar } } : undefined, + * }, + * update: { + * bio: data.update.bio, + * avatar: data.update.avatar ? { connect: { id: data.update.avatar } } : undefined, + * }, * }), - * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, - * deleteRelation: async () => { - * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); - * }, * }), * }; * ``` @@ -236,11 +268,12 @@ export function nestedOneToOneField< TFields >, ): FieldDefinition< - InferInput | null | undefined, + z.ZodOptional>>, undefined, undefined | { delete: true } > { return { + schema: generateCreateSchema(config.fields).nullish(), processInput: async (value, processCtx) => { // Handle null - delete the relation if (value === null) { @@ -542,7 +575,7 @@ export function nestedOneToManyField< TFields >, ): FieldDefinition< - InferInput[] | undefined, + z.ZodOptional>>, undefined, undefined | { deleteMany: Record } > { @@ -560,6 +593,7 @@ export function nestedOneToManyField< }; return { + schema: generateCreateSchema(config.fields).array().optional(), processInput: async (value, processCtx) => { const { serviceContext, loadExisting } = processCtx; diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/types.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/types.ts index aba7c5e6b..b37885d95 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/types.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/data-operations/types.ts @@ -1,4 +1,5 @@ import type { ITXClientDenyList } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; import type { PrismaClient } from '@src/generated/prisma/client.js'; @@ -190,17 +191,18 @@ export interface FieldTransformResult { * Field definition for validating and transforming input values. * * A field definition specifies how to process a single input field: - * - Validate the input value + * - Validate the input value using a Zod schema * - Transform it into Prisma-compatible create/update data * - Optionally attach hooks for side effects * - * @template TInput - The expected input type + * @template TInputSchema - The Zod schema type for validation * @template TCreateOutput - Output type for create operations * @template TUpdateOutput - Output type for update operations * * @example * ```typescript - * const nameField: FieldDefinition = { + * const nameField: FieldDefinition = { + * zodSchema: z.string().min(1), * processInput: (value, ctx) => { * const validated = z.string().min(1).parse(value); * return { @@ -213,16 +215,30 @@ export interface FieldTransformResult { * }; * ``` */ -export interface FieldDefinition { +export interface FieldDefinition< + TInputSchema extends z.ZodSchema, + TCreateOutput, + TUpdateOutput, +> { + /** + * The Zod schema for validating this field's input. + * This schema can be extracted and reused for validation in other contexts + * (e.g., GraphQL mutations, REST endpoints, tRPC procedures). + */ + schema: TInputSchema; + /** * Processes and transforms an input value. * - * @param value - The input value to process + * Note: Validation happens at the operation level (defineCreateOperation/defineUpdateOperation), + * not at the field level. This function receives already-validated input. + * + * @param value - The validated input value to process * @param ctx - Context about the operation * @returns Transformed data and optional hooks */ processInput: ( - value: TInput, + value: z.output, ctx: FieldContext, ) => | Promise> @@ -238,14 +254,6 @@ export type AnyFieldDefinition = FieldDefinition; * ========================================= */ -/** Extracts keys from T where the value type does not include undefined */ -type RequiredKeys = { - [P in keyof T]: T[P] extends Exclude ? P : never; -}[keyof T]; - -/** Makes properties with undefined values optional, keeps others required */ -type OptionalForUndefinedKeys = Partial & Pick>; - /** Identity type that expands type aliases for better IDE tooltips */ type Identity = T extends object ? {} & { @@ -253,12 +261,44 @@ type Identity = T extends object } : T; +/** + * Infers the input schema from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's Zod schema type + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type InputSchema = InferInputSchema; + * // { name: z.ZodString; email?: z.ZodString | undefined } + * ``` + */ +export type InferInputSchema< + TFields extends Record, +> = z.ZodObject<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInputSchema, + any, + any + > + ? TInputSchema + : never; +}>; + /** * Infers the input type from a record of field definitions. * * Creates an object type where: * - Each key corresponds to a field name - * - Each value type is the field's input type + * - Each value type is the field's Zod schema type * - Fields accepting undefined become optional properties * * @template TFields - Record of field definitions @@ -275,17 +315,7 @@ type Identity = T extends object * ``` */ export type InferInput> = - Identity< - OptionalForUndefinedKeys<{ - [K in keyof TFields]: TFields[K] extends FieldDefinition< - infer TInput, - any, - any - > - ? TInput - : never; - }> - >; + z.output>; /** * Infers the output type (create and update) from a single field definition. diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/normalize-types.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/normalize-types.ts deleted file mode 100644 index 79ed1f313..000000000 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/normalize-types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Typescript hack to force IDEs to show raw object - * type without additional typing that we have added - */ -export type NormalizeTypes = - T extends Record - ? { - [K in keyof T]: T[K]; - } - : T; diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/nulls.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/nulls.ts deleted file mode 100644 index a80df86cb..000000000 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/utils/nulls.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { NormalizeTypes } from './normalize-types.js'; - -/** - * Restricts an object from having null in certain fields. This - * is useful when you want to enforce certain fields are not null - * when coming from GraphQL which only has the option of - * defining null | undefined fields. - * - * @param object Object to validate - * @param restrictedKeys Prevents these keys from being set to null - * @returns A newly typed object whose restrictedKeys are not null - */ -export function restrictObjectNulls< - ObjectType extends Record, - NonNullKeys extends keyof ObjectType, ->( - object: ObjectType, - nonNullKeys: NonNullKeys[], -): NormalizeTypes< - ObjectType & { [K in NonNullKeys]: Exclude } -> { - const nullKey = nonNullKeys.find((key) => object[key] === null); - if (nullKey) { - throw new Error(`${nullKey.toString()} cannot be null`); - } - return object as NormalizeTypes< - ObjectType & { - [K in NonNullKeys]: Exclude; - } - >; -} diff --git a/examples/blog-with-auth/apps/backend/package.json b/examples/blog-with-auth/apps/backend/package.json index f83614140..973572322 100644 --- a/examples/blog-with-auth/apps/backend/package.json +++ b/examples/blog-with-auth/apps/backend/package.json @@ -43,6 +43,7 @@ "@pothos/plugin-relay": "4.6.2", "@pothos/plugin-simple-objects": "4.1.3", "@pothos/plugin-tracing": "1.1.0", + "@pothos/plugin-validation": "4.2.0", "@pothos/tracing-sentry": "1.1.1", "@prisma/adapter-pg": "6.17.1", "@prisma/client": "6.17.1", diff --git a/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts b/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts index 679d3b926..a91835609 100644 --- a/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts +++ b/examples/blog-with-auth/apps/backend/src/modules/accounts/schema/user.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { createUser, @@ -10,13 +9,15 @@ import { } from '../services/user.data-service.js'; import { userObjectType } from './user.object-type.js'; -const createUserDataInputType = builder.inputType('CreateUserData', { - fields: (t) => ({ - email: t.string(), - name: t.string(), - emailVerified: t.boolean(), - }), -}); +const createUserDataInputType = builder + .inputType('CreateUserData', { + fields: (t) => ({ + email: t.string(), + name: t.string(), + emailVerified: t.boolean(), + }), + }) + .validate(createUser.$dataSchema); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ @@ -27,22 +28,25 @@ builder.mutationField('createUser', (t) => authorize: ['admin'], resolve: async (root, { input: { data } }, context, info) => { const user = await createUser({ - data: restrictObjectNulls(data, ['emailVerified']), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, }), ); -const updateUserDataInputType = builder.inputType('UpdateUserData', { - fields: (t) => ({ - email: t.string(), - name: t.string(), - emailVerified: t.boolean(), - }), -}); +const updateUserDataInputType = builder + .inputType('UpdateUserData', { + fields: (t) => ({ + email: t.string(), + name: t.string(), + emailVerified: t.boolean(), + }), + }) + .validate(updateUser.$dataSchema); builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ @@ -55,9 +59,10 @@ builder.mutationField('updateUser', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ where: { id }, - data: restrictObjectNulls(data, ['emailVerified']), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, diff --git a/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts b/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts index 6020c5448..84514f678 100644 --- a/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts +++ b/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts @@ -11,6 +11,7 @@ import type { PothosFieldWithInputPayloadPlugin } from './index.js'; import type { MutationWithInputPayloadOptions, OutputShapeFromFields, + PayloadFieldRef, } from './types.js'; declare global { @@ -56,10 +57,7 @@ declare global { payload: RootFieldBuilder; fieldWithInputPayload: < InputFields extends InputFieldMap, - PayloadFields extends Record< - string, - FieldRef - >, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, Args extends InputFieldMap = Record, @@ -79,7 +77,7 @@ declare global { ShapeFromTypeParam< Types, ObjectRef>, - false + Types['DefaultFieldNullability'] > >; } diff --git a/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts b/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts index 1a355a40b..12ae4225e 100644 --- a/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts +++ b/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts @@ -8,6 +8,8 @@ import { import { capitalizeString } from '@src/utils/string.js'; +import type { PayloadFieldRef } from './types.js'; + const rootBuilderProto = RootFieldBuilder.prototype as PothosSchemaTypes.RootFieldBuilder< SchemaTypes, @@ -27,11 +29,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ // expose all fields of payload by default const payloadFields = (): Record< string, - FieldRef + PayloadFieldRef > => { for (const key of Object.keys(payload)) { payload[key].onFirstUse((cfg) => { - if (cfg.kind === 'Object') { + if (cfg.kind === 'Object' && !cfg.resolve) { cfg.resolve = (parent) => (parent as Record)[key] as Readonly; } @@ -56,9 +58,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ type: payloadRef, nullable: false, ...fieldOptions, - } as never); + } as never) as FieldRef; - fieldRef.onFirstUse((config) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fieldRef.onFirstUse((config: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const capitalizedName = capitalizeString(config.name); const inputName = `${capitalizedName}Input`; const payloadName = `${capitalizedName}Payload`; @@ -66,6 +70,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ if (inputRef) { inputRef.name = inputName; this.builder.inputType(inputRef, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Input type for ${config.name} mutation`, fields: () => input, }); @@ -74,6 +79,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ payloadRef.name = payloadName; this.builder.objectType(payloadRef, { name: payloadName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Payload type for ${config.name} mutation`, fields: payloadFields, }); diff --git a/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts b/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts index bf2e7d518..90b3586cd 100644 --- a/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts +++ b/examples/blog-with-auth/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts @@ -12,6 +12,11 @@ import type { SchemaTypes, } from '@pothos/core'; +export type PayloadFieldRef = Omit< + FieldRef, + 'validate' +>; + export type OutputShapeFromFields = NullableToOptional<{ [K in keyof Fields]: Fields[K] extends GenericFieldRef ? T : never; @@ -23,7 +28,7 @@ export type MutationWithInputPayloadOptions< Kind extends FieldKind, Args extends InputFieldMap, InputFields extends InputFieldMap, - PayloadFields extends Record>, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, > = Omit< diff --git a/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts b/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts index 5e1d36181..5ecb1761f 100644 --- a/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts +++ b/examples/blog-with-auth/apps/backend/src/plugins/graphql/builder.ts @@ -3,6 +3,7 @@ import PrismaPlugin from '@pothos/plugin-prisma'; import RelayPlugin from '@pothos/plugin-relay'; import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'; import TracingPlugin, { isRootField } from '@pothos/plugin-tracing'; +import ValidationPlugin from '@pothos/plugin-validation'; import { createSentryWrapper } from '@pothos/tracing-sentry'; import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; @@ -57,6 +58,7 @@ export const builder = new SchemaBuilder<{ pothosStripQueryMutationPlugin, RelayPlugin, SimpleObjectsPlugin, + ValidationPlugin, ], prisma: { client: prisma, diff --git a/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json b/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json index d1eb02eee..4a24aa145 100644 --- a/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json +++ b/examples/blog-with-auth/apps/backend/src/utils/.templates-info.json @@ -9,16 +9,6 @@ "instanceData": {}, "template": "http-errors" }, - "normalize-types.ts": { - "generator": "@baseplate-dev/core-generators#node/ts-utils", - "instanceData": {}, - "template": "normalize-types" - }, - "nulls.ts": { - "generator": "@baseplate-dev/core-generators#node/ts-utils", - "instanceData": {}, - "template": "nulls" - }, "request-service-context.ts": { "generator": "@baseplate-dev/fastify-generators#core/request-service-context", "instanceData": {}, diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/define-operations.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/define-operations.ts index b571281de..9c584252c 100644 --- a/examples/blog-with-auth/apps/backend/src/utils/data-operations/define-operations.ts +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/define-operations.ts @@ -1,5 +1,7 @@ import type { Result } from '@prisma/client/runtime/client'; +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import { prisma } from '@src/services/prisma.js'; @@ -20,6 +22,7 @@ import type { InferFieldsOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, OperationHooks, PrismaTransaction, @@ -205,6 +208,49 @@ export async function transformFields< return { data: transformedData, hooks }; } +/** + * ========================================= + * Schema Generation Utilities + * ========================================= + */ + +/** + * Generates a Zod schema for create operations from field definitions. + * + * Extracts the Zod schema from each field definition and combines them + * into a single object schema. This schema can be used for validation + * in GraphQL resolvers, REST endpoints, tRPC procedures, or OpenAPI documentation. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to extract schemas from + * @returns Zod object schema with all fields required + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }; + * + * const schema = generateCreateSchema(fields); + * // schema is z.object({ name: z.string(), email: z.string().email() }) + * + * // Use for validation + * const validated = schema.parse({ name: 'John', email: 'john@example.com' }); + * ``` + */ +export function generateCreateSchema< + TFields extends Record, +>(fields: TFields): InferInputSchema { + const shape = Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.schema]), + ) as { + [K in keyof TFields]: TFields[K]['schema']; + }; + + return z.object(shape) as InferInputSchema; +} + /** * ========================================= * Create Operation @@ -273,6 +319,9 @@ export interface CreateOperationConfig< > >; + /** + * Optional hooks for the operation + */ hooks?: OperationHooks>; } @@ -294,8 +343,22 @@ export interface CreateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type CreateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: CreateOperationInput, +) => Promise>) & { + $dataSchema: InferInputSchema; +}; + /** * Defines a type-safe create operation for a Prisma model. * @@ -349,14 +412,17 @@ export function defineCreateOperation< >, >( config: CreateOperationConfig, -): >( - input: CreateOperationInput, -) => Promise> { - return async >({ +): CreateOperationFunction { + const dataSchema = generateCreateSchema(config.fields); + + const createOperation = async >({ data, query, context, - }: CreateOperationInput) => { + skipValidation, + }: CreateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -364,6 +430,9 @@ export function defineCreateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation ? data : dataSchema.parse(data); + const baseOperationContext: OperationContext< GetPayload, { hasResult: false } @@ -376,20 +445,20 @@ export function defineCreateOperation< // Authorization if (config.authorize) { - await config.authorize(data, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } // Step 1: Transform fields (OUTSIDE TRANSACTION) const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(config.fields, data, { + transformFields(config.fields, validatedData, { operation: 'create', serviceContext: context, allowOptionalFields: false, loadExisting: () => Promise.resolve(undefined), }), config.prepareComputedFields - ? config.prepareComputedFields(data, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -451,6 +520,8 @@ export function defineCreateOperation< return result as GetPayload; }); }; + createOperation.$dataSchema = dataSchema; + return createOperation; } /** @@ -549,8 +620,23 @@ export interface UpdateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type UpdateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: UpdateOperationInput, +) => Promise>) & { + $dataSchema: z.ZodObject<{ + [k in keyof TFields]: z.ZodOptional; + }>; +}; /** * Defines a type-safe update operation for a Prisma model. * @@ -605,15 +691,18 @@ export function defineUpdateOperation< >, >( config: UpdateOperationConfig, -): >( - input: UpdateOperationInput, -) => Promise> { - return async >({ +): UpdateOperationFunction { + const dataSchema = generateCreateSchema(config.fields).partial(); + + const updateOperation = async >({ where, data: inputData, query, context, - }: UpdateOperationInput) => { + skipValidation, + }: UpdateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -621,6 +710,11 @@ export function defineUpdateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation + ? inputData + : dataSchema.parse(inputData); + let existingItem: GetPayload | undefined; const delegate = makeGenericPrismaDelegate(prisma, config.model); @@ -644,27 +738,31 @@ export function defineUpdateOperation< }; // Authorization if (config.authorize) { - await config.authorize(inputData, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } - // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Step 1: Transform fields (outside transaction) // Only transform fields provided in input const fieldsToTransform = Object.fromEntries( - Object.entries(config.fields).filter(([key]) => key in inputData), + Object.entries(config.fields).filter(([key]) => key in validatedData), ) as TFields; const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(fieldsToTransform, inputData as InferInput, { - operation: 'update', - serviceContext: context, - allowOptionalFields: true, - loadExisting: baseOperationContext.loadExisting as () => Promise< - Record - >, - }), + transformFields( + fieldsToTransform, + validatedData as InferInput, + { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }, + ), config.prepareComputedFields - ? config.prepareComputedFields(inputData, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -728,6 +826,8 @@ export function defineUpdateOperation< return result as GetPayload; }); }; + updateOperation.$dataSchema = generateCreateSchema(config.fields).partial(); + return updateOperation; } /** diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/field-definitions.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/field-definitions.ts index ab87233ae..f12c65e00 100644 --- a/examples/blog-with-auth/apps/backend/src/utils/data-operations/field-definitions.ts +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/field-definitions.ts @@ -17,47 +17,75 @@ import type { InferFieldsCreateOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, TransactionalOperationContext, } from './types.js'; -import { invokeHooks, transformFields } from './define-operations.js'; +import { + generateCreateSchema, + invokeHooks, + transformFields, +} from './define-operations.js'; import { makeGenericPrismaDelegate } from './prisma-utils.js'; /** - * Create a simple scalar field with validation only + * Create a simple scalar field with validation and optional transformation * * This helper creates a field definition that validates input using a Zod schema. - * The validated value is passed through unchanged to the transform step. + * Optionally, you can provide a transform function to convert the validated value + * into a different type for Prisma operations. * * For relation fields (e.g., `userId`), use this helper to validate the ID, * then use relation helpers in the transform step to create Prisma connect/disconnect objects. * + * @template TSchema - The Zod schema type for validation + * @template TTransformed - The output type after transformation (defaults to schema output) * @param schema - Zod schema for validation + * @param options - Optional configuration + * @param options.transform - Function to transform the validated value * @returns Field definition * * @example * ```typescript + * // Simple validation * const fields = { * title: scalarField(z.string()), * ownerId: scalarField(z.string()), // Validated as string * }; * - * // In transform, convert IDs to relations: - * transform: (data) => ({ - * title: data.title, - * owner: relation.required(data.ownerId), - * }) + * // With transformation + * const fields = { + * email: scalarField( + * z.string().email(), + * { transform: (email) => email.toLowerCase() } + * ), + * createdAt: scalarField( + * z.string().datetime(), + * { transform: (dateStr) => new Date(dateStr) } + * ), + * }; * ``` */ -export function scalarField( +export function scalarField< + TSchema extends z.ZodSchema, + TTransformed = z.output, +>( schema: TSchema, -): FieldDefinition, z.output, z.output> { + options?: { + transform?: (value: z.output) => TTransformed; + }, +): FieldDefinition { return { + schema, processInput: (value) => { - const validated = schema.parse(value) as z.output; + // Apply transform if provided + const transformed = options?.transform + ? options.transform(value) + : (value as TTransformed); + return { - data: { create: validated, update: validated }, + data: { create: transformed, update: transformed }, }; }, }; @@ -190,15 +218,13 @@ export interface NestedOneToOneFieldConfig< * This helper creates a field definition for managing one-to-one nested relationships. * It handles nested field validation, transformation, and supports both create and update operations. * - * For create operations: - * - Returns nested create data if input is provided - * - Returns undefined if input is not provided + * The nested entity is created/updated via afterExecute hooks, allowing it to reference + * the parent entity after it has been created. * - * For update operations: - * - Returns upsert if input has a unique identifier (via getWhereUnique) - * - Returns create if input doesn't have a unique identifier - * - Deletes the relation if input is null (requires deleteRelation) - * - Returns undefined if input is not provided (no change) + * Behavior: + * - **Provided value**: Upserts the nested entity (creates if new, updates if exists) + * - **null**: Deletes the nested entity (update only) + * - **undefined**: No change to nested entity * * @param config - Configuration object * @returns Field definition @@ -207,18 +233,24 @@ export interface NestedOneToOneFieldConfig< * ```typescript * const fields = { * userProfile: nestedOneToOneField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userProfile', + * relationName: 'user', * fields: { * bio: scalarField(z.string()), * avatar: fileField(avatarFileCategory), * }, + * getWhereUnique: (parent) => ({ userId: parent.id }), * buildData: (data) => ({ - * bio: data.bio, - * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * create: { + * bio: data.create.bio, + * avatar: data.create.avatar ? { connect: { id: data.create.avatar } } : undefined, + * }, + * update: { + * bio: data.update.bio, + * avatar: data.update.avatar ? { connect: { id: data.update.avatar } } : undefined, + * }, * }), - * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, - * deleteRelation: async () => { - * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); - * }, * }), * }; * ``` @@ -236,11 +268,12 @@ export function nestedOneToOneField< TFields >, ): FieldDefinition< - InferInput | null | undefined, + z.ZodOptional>>, undefined, undefined | { delete: true } > { return { + schema: generateCreateSchema(config.fields).nullish(), processInput: async (value, processCtx) => { // Handle null - delete the relation if (value === null) { @@ -542,7 +575,7 @@ export function nestedOneToManyField< TFields >, ): FieldDefinition< - InferInput[] | undefined, + z.ZodOptional>>, undefined, undefined | { deleteMany: Record } > { @@ -560,6 +593,7 @@ export function nestedOneToManyField< }; return { + schema: generateCreateSchema(config.fields).array().optional(), processInput: async (value, processCtx) => { const { serviceContext, loadExisting } = processCtx; diff --git a/examples/blog-with-auth/apps/backend/src/utils/data-operations/types.ts b/examples/blog-with-auth/apps/backend/src/utils/data-operations/types.ts index aba7c5e6b..b37885d95 100644 --- a/examples/blog-with-auth/apps/backend/src/utils/data-operations/types.ts +++ b/examples/blog-with-auth/apps/backend/src/utils/data-operations/types.ts @@ -1,4 +1,5 @@ import type { ITXClientDenyList } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; import type { PrismaClient } from '@src/generated/prisma/client.js'; @@ -190,17 +191,18 @@ export interface FieldTransformResult { * Field definition for validating and transforming input values. * * A field definition specifies how to process a single input field: - * - Validate the input value + * - Validate the input value using a Zod schema * - Transform it into Prisma-compatible create/update data * - Optionally attach hooks for side effects * - * @template TInput - The expected input type + * @template TInputSchema - The Zod schema type for validation * @template TCreateOutput - Output type for create operations * @template TUpdateOutput - Output type for update operations * * @example * ```typescript - * const nameField: FieldDefinition = { + * const nameField: FieldDefinition = { + * zodSchema: z.string().min(1), * processInput: (value, ctx) => { * const validated = z.string().min(1).parse(value); * return { @@ -213,16 +215,30 @@ export interface FieldTransformResult { * }; * ``` */ -export interface FieldDefinition { +export interface FieldDefinition< + TInputSchema extends z.ZodSchema, + TCreateOutput, + TUpdateOutput, +> { + /** + * The Zod schema for validating this field's input. + * This schema can be extracted and reused for validation in other contexts + * (e.g., GraphQL mutations, REST endpoints, tRPC procedures). + */ + schema: TInputSchema; + /** * Processes and transforms an input value. * - * @param value - The input value to process + * Note: Validation happens at the operation level (defineCreateOperation/defineUpdateOperation), + * not at the field level. This function receives already-validated input. + * + * @param value - The validated input value to process * @param ctx - Context about the operation * @returns Transformed data and optional hooks */ processInput: ( - value: TInput, + value: z.output, ctx: FieldContext, ) => | Promise> @@ -238,14 +254,6 @@ export type AnyFieldDefinition = FieldDefinition; * ========================================= */ -/** Extracts keys from T where the value type does not include undefined */ -type RequiredKeys = { - [P in keyof T]: T[P] extends Exclude ? P : never; -}[keyof T]; - -/** Makes properties with undefined values optional, keeps others required */ -type OptionalForUndefinedKeys = Partial & Pick>; - /** Identity type that expands type aliases for better IDE tooltips */ type Identity = T extends object ? {} & { @@ -253,12 +261,44 @@ type Identity = T extends object } : T; +/** + * Infers the input schema from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's Zod schema type + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type InputSchema = InferInputSchema; + * // { name: z.ZodString; email?: z.ZodString | undefined } + * ``` + */ +export type InferInputSchema< + TFields extends Record, +> = z.ZodObject<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInputSchema, + any, + any + > + ? TInputSchema + : never; +}>; + /** * Infers the input type from a record of field definitions. * * Creates an object type where: * - Each key corresponds to a field name - * - Each value type is the field's input type + * - Each value type is the field's Zod schema type * - Fields accepting undefined become optional properties * * @template TFields - Record of field definitions @@ -275,17 +315,7 @@ type Identity = T extends object * ``` */ export type InferInput> = - Identity< - OptionalForUndefinedKeys<{ - [K in keyof TFields]: TFields[K] extends FieldDefinition< - infer TInput, - any, - any - > - ? TInput - : never; - }> - >; + z.output>; /** * Infers the output type (create and update) from a single field definition. diff --git a/examples/blog-with-auth/apps/backend/src/utils/normalize-types.ts b/examples/blog-with-auth/apps/backend/src/utils/normalize-types.ts deleted file mode 100644 index 79ed1f313..000000000 --- a/examples/blog-with-auth/apps/backend/src/utils/normalize-types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Typescript hack to force IDEs to show raw object - * type without additional typing that we have added - */ -export type NormalizeTypes = - T extends Record - ? { - [K in keyof T]: T[K]; - } - : T; diff --git a/examples/blog-with-auth/apps/backend/src/utils/nulls.ts b/examples/blog-with-auth/apps/backend/src/utils/nulls.ts deleted file mode 100644 index a80df86cb..000000000 --- a/examples/blog-with-auth/apps/backend/src/utils/nulls.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { NormalizeTypes } from './normalize-types.js'; - -/** - * Restricts an object from having null in certain fields. This - * is useful when you want to enforce certain fields are not null - * when coming from GraphQL which only has the option of - * defining null | undefined fields. - * - * @param object Object to validate - * @param restrictedKeys Prevents these keys from being set to null - * @returns A newly typed object whose restrictedKeys are not null - */ -export function restrictObjectNulls< - ObjectType extends Record, - NonNullKeys extends keyof ObjectType, ->( - object: ObjectType, - nonNullKeys: NonNullKeys[], -): NormalizeTypes< - ObjectType & { [K in NonNullKeys]: Exclude } -> { - const nullKey = nonNullKeys.find((key) => object[key] === null); - if (nullKey) { - throw new Error(`${nullKey.toString()} cannot be null`); - } - return object as NormalizeTypes< - ObjectType & { - [K in NonNullKeys]: Exclude; - } - >; -} diff --git a/examples/blog-with-auth/pnpm-lock.yaml b/examples/blog-with-auth/pnpm-lock.yaml index 2f66fc9c9..5ae651156 100644 --- a/examples/blog-with-auth/pnpm-lock.yaml +++ b/examples/blog-with-auth/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@pothos/plugin-tracing': specifier: 1.1.0 version: 1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) + '@pothos/plugin-validation': + specifier: 4.2.0 + version: 4.2.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) '@pothos/tracing-sentry': specifier: 1.1.1 version: 1.1.1(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(@sentry/node@9.17.0)(graphql@16.11.0) @@ -1691,6 +1694,12 @@ packages: '@pothos/core': '*' graphql: '>=16.6.0' + '@pothos/plugin-validation@4.2.0': + resolution: {integrity: sha512-iqqlNzHGhTbWIf6dfouMjJfh72gOQEft7gp0Bl0vhW8WNWuOwVmrXrXtF+Lu3hyZuBY7WfQ44jQ41hXjYmqx0g==} + peerDependencies: + '@pothos/core': '*' + graphql: ^16.10.0 + '@pothos/tracing-sentry@1.1.1': resolution: {integrity: sha512-r9loI0Fc8Qax1dNxM/IuWj+66ApyyE7WamOIdXNC3uKNqIgx71diXOY/74TBSctRabra+z+0bXsQyI3voWcftw==} peerDependencies: @@ -7833,6 +7842,11 @@ snapshots: '@pothos/core': 4.10.0(graphql@16.11.0) graphql: 16.11.0 + '@pothos/plugin-validation@4.2.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)': + dependencies: + '@pothos/core': 4.10.0(graphql@16.11.0) + graphql: 16.11.0 + '@pothos/tracing-sentry@1.1.1(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(@sentry/node@9.17.0)(graphql@16.11.0)': dependencies: '@pothos/core': 4.10.0(graphql@16.11.0) diff --git a/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json b/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json index 0d861f62f..7cefad59e 100644 --- a/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json +++ b/examples/todo-with-auth0/apps/backend/.baseplate-snapshot/manifest.json @@ -9,8 +9,7 @@ "src/modules/todos/services/todo-item-service.int.test.ts", "src/services/sentry.e2e.test.ts", "src/services/sentry.instrument.test-helper.ts", - "src/services/sentry.test-kit.test-helper.ts", - "src/utils/nulls.unit.test.ts" + "src/services/sentry.test-kit.test-helper.ts" ], "deleted": [], "modified": [{ "diffFile": "package.json.diff", "path": "package.json" }] diff --git a/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json b/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json index 47adbf7fa..edaaa232f 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json +++ b/examples/todo-with-auth0/apps/backend/baseplate/file-id-map.json @@ -5,8 +5,6 @@ "@baseplate-dev/core-generators#node/node:package-json": "package.json", "@baseplate-dev/core-generators#node/prettier:prettier-config": ".prettierrc", "@baseplate-dev/core-generators#node/prettier:prettier-ignore": ".prettierignore", - "@baseplate-dev/core-generators#node/ts-utils:normalize-types": "src/utils/normalize-types.ts", - "@baseplate-dev/core-generators#node/ts-utils:nulls": "src/utils/nulls.ts", "@baseplate-dev/core-generators#node/ts-utils:string": "src/utils/string.ts", "@baseplate-dev/core-generators#node/typescript:tsconfig": "tsconfig.json", "@baseplate-dev/core-generators#node/vitest:global-setup": "src/tests/scripts/global-setup.ts", diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/package.json b/examples/todo-with-auth0/apps/backend/baseplate/generated/package.json index d26f80a89..c7ec17215 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/package.json +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/package.json @@ -48,6 +48,7 @@ "@pothos/plugin-relay": "4.6.2", "@pothos/plugin-simple-objects": "4.1.3", "@pothos/plugin-tracing": "1.1.0", + "@pothos/plugin-validation": "4.2.0", "@pothos/tracing-sentry": "1.1.1", "@prisma/adapter-pg": "6.17.1", "@prisma/client": "6.17.1", diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts index 6960a881d..a07154453 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/accounts/users/schema/user.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { fileInputInputType } from '../../../storage/schema/file-input.input-type.js'; import { @@ -48,16 +47,18 @@ const userUserProfileNestedInputInputType = builder.inputType( }, ); -const createUserDataInputType = builder.inputType('CreateUserData', { - fields: (t) => ({ - name: t.string(), - email: t.string({ required: true }), - customer: t.field({ type: userCustomerNestedInputInputType }), - images: t.field({ type: [userImagesNestedInputInputType] }), - roles: t.field({ type: [userRolesNestedInputInputType] }), - userProfile: t.field({ type: userUserProfileNestedInputInputType }), - }), -}); +const createUserDataInputType = builder + .inputType('CreateUserData', { + fields: (t) => ({ + name: t.string(), + email: t.string({ required: true }), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), + }), + }) + .validate(createUser.$dataSchema); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ @@ -68,35 +69,28 @@ builder.mutationField('createUser', (t) => authorize: ['admin'], resolve: async (root, { input: { data } }, context, info) => { const user = await createUser({ - data: restrictObjectNulls( - { - ...data, - images: data.images?.map((image) => - restrictObjectNulls(image, ['id']), - ), - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), - }, - ['customer', 'images', 'roles', 'userProfile'], - ), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, }), ); -const updateUserDataInputType = builder.inputType('UpdateUserData', { - fields: (t) => ({ - name: t.string(), - email: t.string(), - customer: t.field({ type: userCustomerNestedInputInputType }), - images: t.field({ type: [userImagesNestedInputInputType] }), - roles: t.field({ type: [userRolesNestedInputInputType] }), - userProfile: t.field({ type: userUserProfileNestedInputInputType }), - }), -}); +const updateUserDataInputType = builder + .inputType('UpdateUserData', { + fields: (t) => ({ + name: t.string(), + email: t.string(), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), + }), + }) + .validate(updateUser.$dataSchema); builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ @@ -109,19 +103,10 @@ builder.mutationField('updateUser', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ where: { id }, - data: restrictObjectNulls( - { - ...data, - images: data.images?.map((image) => - restrictObjectNulls(image, ['id']), - ), - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), - }, - ['email', 'customer', 'images', 'roles', 'userProfile'], - ), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/file-field.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/file-field.ts index 48778aabb..5e5a543bc 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/file-field.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/storage/services/file-field.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import type { FieldDefinition } from '@src/utils/data-operations/types.js'; @@ -8,12 +10,14 @@ import type { FileCategory } from '../types/file-category.js'; import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; +const fileInputSchema = z.object({ + id: z.string().uuid(), +}); + /** * File input type - accepts a file ID string */ -export interface FileInput { - id: string; -} +export type FileInput = z.infer; /** * Configuration for file field handler @@ -79,7 +83,9 @@ export function fileField< >( config: FileFieldConfig, ): FieldDefinition< - TOptional extends true ? FileInput | null | undefined : FileInput, + TOptional extends true + ? z.ZodOptional> + : typeof fileInputSchema, TOptional extends true ? { connect: { id: string } } | undefined : { connect: { id: string } }, @@ -88,7 +94,15 @@ export function fileField< : { connect: { id: string } } | undefined > { return { - processInput: async (value: FileInput | null | undefined, processCtx) => { + schema: (config.optional + ? fileInputSchema.nullish() + : fileInputSchema) as TOptional extends true + ? z.ZodOptional> + : typeof fileInputSchema, + processInput: async ( + value: z.infer | null | undefined, + processCtx, + ) => { const { serviceContext } = processCtx; // Handle null - disconnect the file diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts index 0bfcf219a..b1942c028 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-item.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { createTodoItem, @@ -29,16 +28,18 @@ const todoItemAttachmentsNestedInputInputType = builder.inputType( }, ); -const createTodoItemDataInputType = builder.inputType('CreateTodoItemData', { - fields: (t) => ({ - todoListId: t.field({ required: true, type: 'Uuid' }), - position: t.int({ required: true }), - text: t.string({ required: true }), - done: t.boolean({ required: true }), - assigneeId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), - }), -}); +const createTodoItemDataInputType = builder + .inputType('CreateTodoItemData', { + fields: (t) => ({ + todoListId: t.field({ required: true, type: 'Uuid' }), + position: t.int({ required: true }), + text: t.string({ required: true }), + done: t.boolean({ required: true }), + assigneeId: t.field({ type: 'Uuid' }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), + }), + }) + .validate(createTodoItem.$dataSchema); builder.mutationField('createTodoItem', (t) => t.fieldWithInputPayload({ @@ -52,33 +53,28 @@ builder.mutationField('createTodoItem', (t) => authorize: ['user'], resolve: async (root, { input: { data } }, context, info) => { const todoItem = await createTodoItem({ - data: restrictObjectNulls( - { - ...data, - attachments: data.attachments?.map((attachment) => - restrictObjectNulls(attachment, ['id', 'tags']), - ), - }, - ['attachments'], - ), + data, context, query: queryFromInfo({ context, info, path: ['todoItem'] }), + skipValidation: true, }); return { todoItem }; }, }), ); -const updateTodoItemDataInputType = builder.inputType('UpdateTodoItemData', { - fields: (t) => ({ - todoListId: t.field({ type: 'Uuid' }), - position: t.int(), - text: t.string(), - done: t.boolean(), - assigneeId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), - }), -}); +const updateTodoItemDataInputType = builder + .inputType('UpdateTodoItemData', { + fields: (t) => ({ + todoListId: t.field({ type: 'Uuid' }), + position: t.int(), + text: t.string(), + done: t.boolean(), + assigneeId: t.field({ type: 'Uuid' }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), + }), + }) + .validate(updateTodoItem.$dataSchema); builder.mutationField('updateTodoItem', (t) => t.fieldWithInputPayload({ @@ -94,17 +90,10 @@ builder.mutationField('updateTodoItem', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const todoItem = await updateTodoItem({ where: { id }, - data: restrictObjectNulls( - { - ...data, - attachments: data.attachments?.map((attachment) => - restrictObjectNulls(attachment, ['id', 'tags']), - ), - }, - ['todoListId', 'position', 'text', 'done', 'attachments'], - ), + data, context, query: queryFromInfo({ context, info, path: ['todoItem'] }), + skipValidation: true, }); return { todoItem }; }, diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts index d2f576a20..71edb6498 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list-share.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { createTodoListShare, @@ -13,17 +12,16 @@ import { todoListSharePrimaryKeyInputType, } from './todo-list-share.object-type.js'; -const createTodoListShareDataInputType = builder.inputType( - 'CreateTodoListShareData', - { +const createTodoListShareDataInputType = builder + .inputType('CreateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ required: true, type: 'Uuid' }), userId: t.field({ required: true, type: 'Uuid' }), updatedAt: t.field({ type: 'DateTime' }), createdAt: t.field({ type: 'DateTime' }), }), - }, -); + }) + .validate(createTodoListShare.$dataSchema); builder.mutationField('createTodoListShare', (t) => t.fieldWithInputPayload({ @@ -39,26 +37,26 @@ builder.mutationField('createTodoListShare', (t) => authorize: ['user'], resolve: async (root, { input: { data } }, context, info) => { const todoListShare = await createTodoListShare({ - data: restrictObjectNulls(data, ['updatedAt', 'createdAt']), + data, context, query: queryFromInfo({ context, info, path: ['todoListShare'] }), + skipValidation: true, }); return { todoListShare }; }, }), ); -const updateTodoListShareDataInputType = builder.inputType( - 'UpdateTodoListShareData', - { +const updateTodoListShareDataInputType = builder + .inputType('UpdateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ type: 'Uuid' }), userId: t.field({ type: 'Uuid' }), updatedAt: t.field({ type: 'DateTime' }), createdAt: t.field({ type: 'DateTime' }), }), - }, -); + }) + .validate(updateTodoListShare.$dataSchema); builder.mutationField('updateTodoListShare', (t) => t.fieldWithInputPayload({ @@ -79,14 +77,10 @@ builder.mutationField('updateTodoListShare', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const todoListShare = await updateTodoListShare({ where: { todoListId_userId: id }, - data: restrictObjectNulls(data, [ - 'todoListId', - 'userId', - 'updatedAt', - 'createdAt', - ]), + data, context, query: queryFromInfo({ context, info, path: ['todoListShare'] }), + skipValidation: true, }); return { todoListShare }; }, diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts index 56a0df1dc..5946b533f 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/modules/todos/schema/todo-list.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { fileInputInputType } from '../../storage/schema/file-input.input-type.js'; import { @@ -12,16 +11,18 @@ import { import { todoListStatusEnum } from './enums.js'; import { todoListObjectType } from './todo-list.object-type.js'; -const createTodoListDataInputType = builder.inputType('CreateTodoListData', { - fields: (t) => ({ - ownerId: t.field({ required: true, type: 'Uuid' }), - position: t.int({ required: true }), - name: t.string({ required: true }), - createdAt: t.field({ type: 'DateTime' }), - status: t.field({ type: todoListStatusEnum }), - coverPhoto: t.field({ type: fileInputInputType }), - }), -}); +const createTodoListDataInputType = builder + .inputType('CreateTodoListData', { + fields: (t) => ({ + ownerId: t.field({ required: true, type: 'Uuid' }), + position: t.int({ required: true }), + name: t.string({ required: true }), + createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), + coverPhoto: t.field({ type: fileInputInputType }), + }), + }) + .validate(createTodoList.$dataSchema); builder.mutationField('createTodoList', (t) => t.fieldWithInputPayload({ @@ -35,25 +36,28 @@ builder.mutationField('createTodoList', (t) => authorize: ['user'], resolve: async (root, { input: { data } }, context, info) => { const todoList = await createTodoList({ - data: restrictObjectNulls(data, ['createdAt']), + data, context, query: queryFromInfo({ context, info, path: ['todoList'] }), + skipValidation: true, }); return { todoList }; }, }), ); -const updateTodoListDataInputType = builder.inputType('UpdateTodoListData', { - fields: (t) => ({ - ownerId: t.field({ type: 'Uuid' }), - position: t.int(), - name: t.string(), - createdAt: t.field({ type: 'DateTime' }), - status: t.field({ type: todoListStatusEnum }), - coverPhoto: t.field({ type: fileInputInputType }), - }), -}); +const updateTodoListDataInputType = builder + .inputType('UpdateTodoListData', { + fields: (t) => ({ + ownerId: t.field({ type: 'Uuid' }), + position: t.int(), + name: t.string(), + createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), + coverPhoto: t.field({ type: fileInputInputType }), + }), + }) + .validate(updateTodoList.$dataSchema); builder.mutationField('updateTodoList', (t) => t.fieldWithInputPayload({ @@ -69,14 +73,10 @@ builder.mutationField('updateTodoList', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const todoList = await updateTodoList({ where: { id }, - data: restrictObjectNulls(data, [ - 'ownerId', - 'position', - 'name', - 'createdAt', - ]), + data, context, query: queryFromInfo({ context, info, path: ['todoList'] }), + skipValidation: true, }); return { todoList }; }, diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts index 6020c5448..84514f678 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts @@ -11,6 +11,7 @@ import type { PothosFieldWithInputPayloadPlugin } from './index.js'; import type { MutationWithInputPayloadOptions, OutputShapeFromFields, + PayloadFieldRef, } from './types.js'; declare global { @@ -56,10 +57,7 @@ declare global { payload: RootFieldBuilder; fieldWithInputPayload: < InputFields extends InputFieldMap, - PayloadFields extends Record< - string, - FieldRef - >, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, Args extends InputFieldMap = Record, @@ -79,7 +77,7 @@ declare global { ShapeFromTypeParam< Types, ObjectRef>, - false + Types['DefaultFieldNullability'] > >; } diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts index 1a355a40b..12ae4225e 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts @@ -8,6 +8,8 @@ import { import { capitalizeString } from '@src/utils/string.js'; +import type { PayloadFieldRef } from './types.js'; + const rootBuilderProto = RootFieldBuilder.prototype as PothosSchemaTypes.RootFieldBuilder< SchemaTypes, @@ -27,11 +29,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ // expose all fields of payload by default const payloadFields = (): Record< string, - FieldRef + PayloadFieldRef > => { for (const key of Object.keys(payload)) { payload[key].onFirstUse((cfg) => { - if (cfg.kind === 'Object') { + if (cfg.kind === 'Object' && !cfg.resolve) { cfg.resolve = (parent) => (parent as Record)[key] as Readonly; } @@ -56,9 +58,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ type: payloadRef, nullable: false, ...fieldOptions, - } as never); + } as never) as FieldRef; - fieldRef.onFirstUse((config) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fieldRef.onFirstUse((config: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const capitalizedName = capitalizeString(config.name); const inputName = `${capitalizedName}Input`; const payloadName = `${capitalizedName}Payload`; @@ -66,6 +70,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ if (inputRef) { inputRef.name = inputName; this.builder.inputType(inputRef, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Input type for ${config.name} mutation`, fields: () => input, }); @@ -74,6 +79,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ payloadRef.name = payloadName; this.builder.objectType(payloadRef, { name: payloadName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Payload type for ${config.name} mutation`, fields: payloadFields, }); diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts index bf2e7d518..90b3586cd 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts @@ -12,6 +12,11 @@ import type { SchemaTypes, } from '@pothos/core'; +export type PayloadFieldRef = Omit< + FieldRef, + 'validate' +>; + export type OutputShapeFromFields = NullableToOptional<{ [K in keyof Fields]: Fields[K] extends GenericFieldRef ? T : never; @@ -23,7 +28,7 @@ export type MutationWithInputPayloadOptions< Kind extends FieldKind, Args extends InputFieldMap, InputFields extends InputFieldMap, - PayloadFields extends Record>, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, > = Omit< diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts index 1cf274920..861d4857b 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts @@ -3,6 +3,7 @@ import PrismaPlugin from '@pothos/plugin-prisma'; import RelayPlugin from '@pothos/plugin-relay'; import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'; import TracingPlugin, { isRootField } from '@pothos/plugin-tracing'; +import ValidationPlugin from '@pothos/plugin-validation'; import { createSentryWrapper } from '@pothos/tracing-sentry'; import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; @@ -57,6 +58,7 @@ export const builder = new SchemaBuilder<{ pothosStripQueryMutationPlugin, RelayPlugin, SimpleObjectsPlugin, + ValidationPlugin, ], prisma: { client: prisma, diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts index b571281de..9c584252c 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts @@ -1,5 +1,7 @@ import type { Result } from '@prisma/client/runtime/client'; +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import { prisma } from '@src/services/prisma.js'; @@ -20,6 +22,7 @@ import type { InferFieldsOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, OperationHooks, PrismaTransaction, @@ -205,6 +208,49 @@ export async function transformFields< return { data: transformedData, hooks }; } +/** + * ========================================= + * Schema Generation Utilities + * ========================================= + */ + +/** + * Generates a Zod schema for create operations from field definitions. + * + * Extracts the Zod schema from each field definition and combines them + * into a single object schema. This schema can be used for validation + * in GraphQL resolvers, REST endpoints, tRPC procedures, or OpenAPI documentation. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to extract schemas from + * @returns Zod object schema with all fields required + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }; + * + * const schema = generateCreateSchema(fields); + * // schema is z.object({ name: z.string(), email: z.string().email() }) + * + * // Use for validation + * const validated = schema.parse({ name: 'John', email: 'john@example.com' }); + * ``` + */ +export function generateCreateSchema< + TFields extends Record, +>(fields: TFields): InferInputSchema { + const shape = Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.schema]), + ) as { + [K in keyof TFields]: TFields[K]['schema']; + }; + + return z.object(shape) as InferInputSchema; +} + /** * ========================================= * Create Operation @@ -273,6 +319,9 @@ export interface CreateOperationConfig< > >; + /** + * Optional hooks for the operation + */ hooks?: OperationHooks>; } @@ -294,8 +343,22 @@ export interface CreateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type CreateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: CreateOperationInput, +) => Promise>) & { + $dataSchema: InferInputSchema; +}; + /** * Defines a type-safe create operation for a Prisma model. * @@ -349,14 +412,17 @@ export function defineCreateOperation< >, >( config: CreateOperationConfig, -): >( - input: CreateOperationInput, -) => Promise> { - return async >({ +): CreateOperationFunction { + const dataSchema = generateCreateSchema(config.fields); + + const createOperation = async >({ data, query, context, - }: CreateOperationInput) => { + skipValidation, + }: CreateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -364,6 +430,9 @@ export function defineCreateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation ? data : dataSchema.parse(data); + const baseOperationContext: OperationContext< GetPayload, { hasResult: false } @@ -376,20 +445,20 @@ export function defineCreateOperation< // Authorization if (config.authorize) { - await config.authorize(data, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } // Step 1: Transform fields (OUTSIDE TRANSACTION) const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(config.fields, data, { + transformFields(config.fields, validatedData, { operation: 'create', serviceContext: context, allowOptionalFields: false, loadExisting: () => Promise.resolve(undefined), }), config.prepareComputedFields - ? config.prepareComputedFields(data, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -451,6 +520,8 @@ export function defineCreateOperation< return result as GetPayload; }); }; + createOperation.$dataSchema = dataSchema; + return createOperation; } /** @@ -549,8 +620,23 @@ export interface UpdateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type UpdateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: UpdateOperationInput, +) => Promise>) & { + $dataSchema: z.ZodObject<{ + [k in keyof TFields]: z.ZodOptional; + }>; +}; /** * Defines a type-safe update operation for a Prisma model. * @@ -605,15 +691,18 @@ export function defineUpdateOperation< >, >( config: UpdateOperationConfig, -): >( - input: UpdateOperationInput, -) => Promise> { - return async >({ +): UpdateOperationFunction { + const dataSchema = generateCreateSchema(config.fields).partial(); + + const updateOperation = async >({ where, data: inputData, query, context, - }: UpdateOperationInput) => { + skipValidation, + }: UpdateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -621,6 +710,11 @@ export function defineUpdateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation + ? inputData + : dataSchema.parse(inputData); + let existingItem: GetPayload | undefined; const delegate = makeGenericPrismaDelegate(prisma, config.model); @@ -644,27 +738,31 @@ export function defineUpdateOperation< }; // Authorization if (config.authorize) { - await config.authorize(inputData, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } - // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Step 1: Transform fields (outside transaction) // Only transform fields provided in input const fieldsToTransform = Object.fromEntries( - Object.entries(config.fields).filter(([key]) => key in inputData), + Object.entries(config.fields).filter(([key]) => key in validatedData), ) as TFields; const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(fieldsToTransform, inputData as InferInput, { - operation: 'update', - serviceContext: context, - allowOptionalFields: true, - loadExisting: baseOperationContext.loadExisting as () => Promise< - Record - >, - }), + transformFields( + fieldsToTransform, + validatedData as InferInput, + { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }, + ), config.prepareComputedFields - ? config.prepareComputedFields(inputData, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -728,6 +826,8 @@ export function defineUpdateOperation< return result as GetPayload; }); }; + updateOperation.$dataSchema = generateCreateSchema(config.fields).partial(); + return updateOperation; } /** diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts index ab87233ae..f12c65e00 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts @@ -17,47 +17,75 @@ import type { InferFieldsCreateOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, TransactionalOperationContext, } from './types.js'; -import { invokeHooks, transformFields } from './define-operations.js'; +import { + generateCreateSchema, + invokeHooks, + transformFields, +} from './define-operations.js'; import { makeGenericPrismaDelegate } from './prisma-utils.js'; /** - * Create a simple scalar field with validation only + * Create a simple scalar field with validation and optional transformation * * This helper creates a field definition that validates input using a Zod schema. - * The validated value is passed through unchanged to the transform step. + * Optionally, you can provide a transform function to convert the validated value + * into a different type for Prisma operations. * * For relation fields (e.g., `userId`), use this helper to validate the ID, * then use relation helpers in the transform step to create Prisma connect/disconnect objects. * + * @template TSchema - The Zod schema type for validation + * @template TTransformed - The output type after transformation (defaults to schema output) * @param schema - Zod schema for validation + * @param options - Optional configuration + * @param options.transform - Function to transform the validated value * @returns Field definition * * @example * ```typescript + * // Simple validation * const fields = { * title: scalarField(z.string()), * ownerId: scalarField(z.string()), // Validated as string * }; * - * // In transform, convert IDs to relations: - * transform: (data) => ({ - * title: data.title, - * owner: relation.required(data.ownerId), - * }) + * // With transformation + * const fields = { + * email: scalarField( + * z.string().email(), + * { transform: (email) => email.toLowerCase() } + * ), + * createdAt: scalarField( + * z.string().datetime(), + * { transform: (dateStr) => new Date(dateStr) } + * ), + * }; * ``` */ -export function scalarField( +export function scalarField< + TSchema extends z.ZodSchema, + TTransformed = z.output, +>( schema: TSchema, -): FieldDefinition, z.output, z.output> { + options?: { + transform?: (value: z.output) => TTransformed; + }, +): FieldDefinition { return { + schema, processInput: (value) => { - const validated = schema.parse(value) as z.output; + // Apply transform if provided + const transformed = options?.transform + ? options.transform(value) + : (value as TTransformed); + return { - data: { create: validated, update: validated }, + data: { create: transformed, update: transformed }, }; }, }; @@ -190,15 +218,13 @@ export interface NestedOneToOneFieldConfig< * This helper creates a field definition for managing one-to-one nested relationships. * It handles nested field validation, transformation, and supports both create and update operations. * - * For create operations: - * - Returns nested create data if input is provided - * - Returns undefined if input is not provided + * The nested entity is created/updated via afterExecute hooks, allowing it to reference + * the parent entity after it has been created. * - * For update operations: - * - Returns upsert if input has a unique identifier (via getWhereUnique) - * - Returns create if input doesn't have a unique identifier - * - Deletes the relation if input is null (requires deleteRelation) - * - Returns undefined if input is not provided (no change) + * Behavior: + * - **Provided value**: Upserts the nested entity (creates if new, updates if exists) + * - **null**: Deletes the nested entity (update only) + * - **undefined**: No change to nested entity * * @param config - Configuration object * @returns Field definition @@ -207,18 +233,24 @@ export interface NestedOneToOneFieldConfig< * ```typescript * const fields = { * userProfile: nestedOneToOneField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userProfile', + * relationName: 'user', * fields: { * bio: scalarField(z.string()), * avatar: fileField(avatarFileCategory), * }, + * getWhereUnique: (parent) => ({ userId: parent.id }), * buildData: (data) => ({ - * bio: data.bio, - * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * create: { + * bio: data.create.bio, + * avatar: data.create.avatar ? { connect: { id: data.create.avatar } } : undefined, + * }, + * update: { + * bio: data.update.bio, + * avatar: data.update.avatar ? { connect: { id: data.update.avatar } } : undefined, + * }, * }), - * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, - * deleteRelation: async () => { - * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); - * }, * }), * }; * ``` @@ -236,11 +268,12 @@ export function nestedOneToOneField< TFields >, ): FieldDefinition< - InferInput | null | undefined, + z.ZodOptional>>, undefined, undefined | { delete: true } > { return { + schema: generateCreateSchema(config.fields).nullish(), processInput: async (value, processCtx) => { // Handle null - delete the relation if (value === null) { @@ -542,7 +575,7 @@ export function nestedOneToManyField< TFields >, ): FieldDefinition< - InferInput[] | undefined, + z.ZodOptional>>, undefined, undefined | { deleteMany: Record } > { @@ -560,6 +593,7 @@ export function nestedOneToManyField< }; return { + schema: generateCreateSchema(config.fields).array().optional(), processInput: async (value, processCtx) => { const { serviceContext, loadExisting } = processCtx; diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/types.ts index aba7c5e6b..b37885d95 100644 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/types.ts +++ b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/data-operations/types.ts @@ -1,4 +1,5 @@ import type { ITXClientDenyList } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; import type { PrismaClient } from '@src/generated/prisma/client.js'; @@ -190,17 +191,18 @@ export interface FieldTransformResult { * Field definition for validating and transforming input values. * * A field definition specifies how to process a single input field: - * - Validate the input value + * - Validate the input value using a Zod schema * - Transform it into Prisma-compatible create/update data * - Optionally attach hooks for side effects * - * @template TInput - The expected input type + * @template TInputSchema - The Zod schema type for validation * @template TCreateOutput - Output type for create operations * @template TUpdateOutput - Output type for update operations * * @example * ```typescript - * const nameField: FieldDefinition = { + * const nameField: FieldDefinition = { + * zodSchema: z.string().min(1), * processInput: (value, ctx) => { * const validated = z.string().min(1).parse(value); * return { @@ -213,16 +215,30 @@ export interface FieldTransformResult { * }; * ``` */ -export interface FieldDefinition { +export interface FieldDefinition< + TInputSchema extends z.ZodSchema, + TCreateOutput, + TUpdateOutput, +> { + /** + * The Zod schema for validating this field's input. + * This schema can be extracted and reused for validation in other contexts + * (e.g., GraphQL mutations, REST endpoints, tRPC procedures). + */ + schema: TInputSchema; + /** * Processes and transforms an input value. * - * @param value - The input value to process + * Note: Validation happens at the operation level (defineCreateOperation/defineUpdateOperation), + * not at the field level. This function receives already-validated input. + * + * @param value - The validated input value to process * @param ctx - Context about the operation * @returns Transformed data and optional hooks */ processInput: ( - value: TInput, + value: z.output, ctx: FieldContext, ) => | Promise> @@ -238,14 +254,6 @@ export type AnyFieldDefinition = FieldDefinition; * ========================================= */ -/** Extracts keys from T where the value type does not include undefined */ -type RequiredKeys = { - [P in keyof T]: T[P] extends Exclude ? P : never; -}[keyof T]; - -/** Makes properties with undefined values optional, keeps others required */ -type OptionalForUndefinedKeys = Partial & Pick>; - /** Identity type that expands type aliases for better IDE tooltips */ type Identity = T extends object ? {} & { @@ -253,12 +261,44 @@ type Identity = T extends object } : T; +/** + * Infers the input schema from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's Zod schema type + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type InputSchema = InferInputSchema; + * // { name: z.ZodString; email?: z.ZodString | undefined } + * ``` + */ +export type InferInputSchema< + TFields extends Record, +> = z.ZodObject<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInputSchema, + any, + any + > + ? TInputSchema + : never; +}>; + /** * Infers the input type from a record of field definitions. * * Creates an object type where: * - Each key corresponds to a field name - * - Each value type is the field's input type + * - Each value type is the field's Zod schema type * - Fields accepting undefined become optional properties * * @template TFields - Record of field definitions @@ -275,17 +315,7 @@ type Identity = T extends object * ``` */ export type InferInput> = - Identity< - OptionalForUndefinedKeys<{ - [K in keyof TFields]: TFields[K] extends FieldDefinition< - infer TInput, - any, - any - > - ? TInput - : never; - }> - >; + z.output>; /** * Infers the output type (create and update) from a single field definition. diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/normalize-types.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/normalize-types.ts deleted file mode 100644 index 79ed1f313..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/normalize-types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Typescript hack to force IDEs to show raw object - * type without additional typing that we have added - */ -export type NormalizeTypes = - T extends Record - ? { - [K in keyof T]: T[K]; - } - : T; diff --git a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/nulls.ts b/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/nulls.ts deleted file mode 100644 index a80df86cb..000000000 --- a/examples/todo-with-auth0/apps/backend/baseplate/generated/src/utils/nulls.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { NormalizeTypes } from './normalize-types.js'; - -/** - * Restricts an object from having null in certain fields. This - * is useful when you want to enforce certain fields are not null - * when coming from GraphQL which only has the option of - * defining null | undefined fields. - * - * @param object Object to validate - * @param restrictedKeys Prevents these keys from being set to null - * @returns A newly typed object whose restrictedKeys are not null - */ -export function restrictObjectNulls< - ObjectType extends Record, - NonNullKeys extends keyof ObjectType, ->( - object: ObjectType, - nonNullKeys: NonNullKeys[], -): NormalizeTypes< - ObjectType & { [K in NonNullKeys]: Exclude } -> { - const nullKey = nonNullKeys.find((key) => object[key] === null); - if (nullKey) { - throw new Error(`${nullKey.toString()} cannot be null`); - } - return object as NormalizeTypes< - ObjectType & { - [K in NonNullKeys]: Exclude; - } - >; -} diff --git a/examples/todo-with-auth0/apps/backend/package.json b/examples/todo-with-auth0/apps/backend/package.json index 11bed24c3..9baf1c93d 100644 --- a/examples/todo-with-auth0/apps/backend/package.json +++ b/examples/todo-with-auth0/apps/backend/package.json @@ -48,6 +48,7 @@ "@pothos/plugin-relay": "4.6.2", "@pothos/plugin-simple-objects": "4.1.3", "@pothos/plugin-tracing": "1.1.0", + "@pothos/plugin-validation": "4.2.0", "@pothos/tracing-sentry": "1.1.1", "@prisma/adapter-pg": "6.17.1", "@prisma/client": "6.17.1", diff --git a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts index 6960a881d..a07154453 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/accounts/users/schema/user.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { fileInputInputType } from '../../../storage/schema/file-input.input-type.js'; import { @@ -48,16 +47,18 @@ const userUserProfileNestedInputInputType = builder.inputType( }, ); -const createUserDataInputType = builder.inputType('CreateUserData', { - fields: (t) => ({ - name: t.string(), - email: t.string({ required: true }), - customer: t.field({ type: userCustomerNestedInputInputType }), - images: t.field({ type: [userImagesNestedInputInputType] }), - roles: t.field({ type: [userRolesNestedInputInputType] }), - userProfile: t.field({ type: userUserProfileNestedInputInputType }), - }), -}); +const createUserDataInputType = builder + .inputType('CreateUserData', { + fields: (t) => ({ + name: t.string(), + email: t.string({ required: true }), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), + }), + }) + .validate(createUser.$dataSchema); builder.mutationField('createUser', (t) => t.fieldWithInputPayload({ @@ -68,35 +69,28 @@ builder.mutationField('createUser', (t) => authorize: ['admin'], resolve: async (root, { input: { data } }, context, info) => { const user = await createUser({ - data: restrictObjectNulls( - { - ...data, - images: data.images?.map((image) => - restrictObjectNulls(image, ['id']), - ), - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), - }, - ['customer', 'images', 'roles', 'userProfile'], - ), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, }), ); -const updateUserDataInputType = builder.inputType('UpdateUserData', { - fields: (t) => ({ - name: t.string(), - email: t.string(), - customer: t.field({ type: userCustomerNestedInputInputType }), - images: t.field({ type: [userImagesNestedInputInputType] }), - roles: t.field({ type: [userRolesNestedInputInputType] }), - userProfile: t.field({ type: userUserProfileNestedInputInputType }), - }), -}); +const updateUserDataInputType = builder + .inputType('UpdateUserData', { + fields: (t) => ({ + name: t.string(), + email: t.string(), + customer: t.field({ type: userCustomerNestedInputInputType }), + images: t.field({ type: [userImagesNestedInputInputType] }), + roles: t.field({ type: [userRolesNestedInputInputType] }), + userProfile: t.field({ type: userUserProfileNestedInputInputType }), + }), + }) + .validate(updateUser.$dataSchema); builder.mutationField('updateUser', (t) => t.fieldWithInputPayload({ @@ -109,19 +103,10 @@ builder.mutationField('updateUser', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const user = await updateUser({ where: { id }, - data: restrictObjectNulls( - { - ...data, - images: data.images?.map((image) => - restrictObjectNulls(image, ['id']), - ), - userProfile: - data.userProfile && restrictObjectNulls(data.userProfile, ['id']), - }, - ['email', 'customer', 'images', 'roles', 'userProfile'], - ), + data, context, query: queryFromInfo({ context, info, path: ['user'] }), + skipValidation: true, }); return { user }; }, diff --git a/examples/todo-with-auth0/apps/backend/src/modules/storage/services/file-field.ts b/examples/todo-with-auth0/apps/backend/src/modules/storage/services/file-field.ts index 48778aabb..5e5a543bc 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/storage/services/file-field.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/storage/services/file-field.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import type { FieldDefinition } from '@src/utils/data-operations/types.js'; @@ -8,12 +10,14 @@ import type { FileCategory } from '../types/file-category.js'; import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; +const fileInputSchema = z.object({ + id: z.string().uuid(), +}); + /** * File input type - accepts a file ID string */ -export interface FileInput { - id: string; -} +export type FileInput = z.infer; /** * Configuration for file field handler @@ -79,7 +83,9 @@ export function fileField< >( config: FileFieldConfig, ): FieldDefinition< - TOptional extends true ? FileInput | null | undefined : FileInput, + TOptional extends true + ? z.ZodOptional> + : typeof fileInputSchema, TOptional extends true ? { connect: { id: string } } | undefined : { connect: { id: string } }, @@ -88,7 +94,15 @@ export function fileField< : { connect: { id: string } } | undefined > { return { - processInput: async (value: FileInput | null | undefined, processCtx) => { + schema: (config.optional + ? fileInputSchema.nullish() + : fileInputSchema) as TOptional extends true + ? z.ZodOptional> + : typeof fileInputSchema, + processInput: async ( + value: z.infer | null | undefined, + processCtx, + ) => { const { serviceContext } = processCtx; // Handle null - disconnect the file diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts index 0bfcf219a..b1942c028 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-item.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { createTodoItem, @@ -29,16 +28,18 @@ const todoItemAttachmentsNestedInputInputType = builder.inputType( }, ); -const createTodoItemDataInputType = builder.inputType('CreateTodoItemData', { - fields: (t) => ({ - todoListId: t.field({ required: true, type: 'Uuid' }), - position: t.int({ required: true }), - text: t.string({ required: true }), - done: t.boolean({ required: true }), - assigneeId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), - }), -}); +const createTodoItemDataInputType = builder + .inputType('CreateTodoItemData', { + fields: (t) => ({ + todoListId: t.field({ required: true, type: 'Uuid' }), + position: t.int({ required: true }), + text: t.string({ required: true }), + done: t.boolean({ required: true }), + assigneeId: t.field({ type: 'Uuid' }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), + }), + }) + .validate(createTodoItem.$dataSchema); builder.mutationField('createTodoItem', (t) => t.fieldWithInputPayload({ @@ -52,33 +53,28 @@ builder.mutationField('createTodoItem', (t) => authorize: ['user'], resolve: async (root, { input: { data } }, context, info) => { const todoItem = await createTodoItem({ - data: restrictObjectNulls( - { - ...data, - attachments: data.attachments?.map((attachment) => - restrictObjectNulls(attachment, ['id', 'tags']), - ), - }, - ['attachments'], - ), + data, context, query: queryFromInfo({ context, info, path: ['todoItem'] }), + skipValidation: true, }); return { todoItem }; }, }), ); -const updateTodoItemDataInputType = builder.inputType('UpdateTodoItemData', { - fields: (t) => ({ - todoListId: t.field({ type: 'Uuid' }), - position: t.int(), - text: t.string(), - done: t.boolean(), - assigneeId: t.field({ type: 'Uuid' }), - attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), - }), -}); +const updateTodoItemDataInputType = builder + .inputType('UpdateTodoItemData', { + fields: (t) => ({ + todoListId: t.field({ type: 'Uuid' }), + position: t.int(), + text: t.string(), + done: t.boolean(), + assigneeId: t.field({ type: 'Uuid' }), + attachments: t.field({ type: [todoItemAttachmentsNestedInputInputType] }), + }), + }) + .validate(updateTodoItem.$dataSchema); builder.mutationField('updateTodoItem', (t) => t.fieldWithInputPayload({ @@ -94,17 +90,10 @@ builder.mutationField('updateTodoItem', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const todoItem = await updateTodoItem({ where: { id }, - data: restrictObjectNulls( - { - ...data, - attachments: data.attachments?.map((attachment) => - restrictObjectNulls(attachment, ['id', 'tags']), - ), - }, - ['todoListId', 'position', 'text', 'done', 'attachments'], - ), + data, context, query: queryFromInfo({ context, info, path: ['todoItem'] }), + skipValidation: true, }); return { todoItem }; }, diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts index d2f576a20..71edb6498 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list-share.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { createTodoListShare, @@ -13,17 +12,16 @@ import { todoListSharePrimaryKeyInputType, } from './todo-list-share.object-type.js'; -const createTodoListShareDataInputType = builder.inputType( - 'CreateTodoListShareData', - { +const createTodoListShareDataInputType = builder + .inputType('CreateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ required: true, type: 'Uuid' }), userId: t.field({ required: true, type: 'Uuid' }), updatedAt: t.field({ type: 'DateTime' }), createdAt: t.field({ type: 'DateTime' }), }), - }, -); + }) + .validate(createTodoListShare.$dataSchema); builder.mutationField('createTodoListShare', (t) => t.fieldWithInputPayload({ @@ -39,26 +37,26 @@ builder.mutationField('createTodoListShare', (t) => authorize: ['user'], resolve: async (root, { input: { data } }, context, info) => { const todoListShare = await createTodoListShare({ - data: restrictObjectNulls(data, ['updatedAt', 'createdAt']), + data, context, query: queryFromInfo({ context, info, path: ['todoListShare'] }), + skipValidation: true, }); return { todoListShare }; }, }), ); -const updateTodoListShareDataInputType = builder.inputType( - 'UpdateTodoListShareData', - { +const updateTodoListShareDataInputType = builder + .inputType('UpdateTodoListShareData', { fields: (t) => ({ todoListId: t.field({ type: 'Uuid' }), userId: t.field({ type: 'Uuid' }), updatedAt: t.field({ type: 'DateTime' }), createdAt: t.field({ type: 'DateTime' }), }), - }, -); + }) + .validate(updateTodoListShare.$dataSchema); builder.mutationField('updateTodoListShare', (t) => t.fieldWithInputPayload({ @@ -79,14 +77,10 @@ builder.mutationField('updateTodoListShare', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const todoListShare = await updateTodoListShare({ where: { todoListId_userId: id }, - data: restrictObjectNulls(data, [ - 'todoListId', - 'userId', - 'updatedAt', - 'createdAt', - ]), + data, context, query: queryFromInfo({ context, info, path: ['todoListShare'] }), + skipValidation: true, }); return { todoListShare }; }, diff --git a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts index 56a0df1dc..5946b533f 100644 --- a/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts +++ b/examples/todo-with-auth0/apps/backend/src/modules/todos/schema/todo-list.mutations.ts @@ -1,7 +1,6 @@ import { queryFromInfo } from '@pothos/plugin-prisma'; import { builder } from '@src/plugins/graphql/builder.js'; -import { restrictObjectNulls } from '@src/utils/nulls.js'; import { fileInputInputType } from '../../storage/schema/file-input.input-type.js'; import { @@ -12,16 +11,18 @@ import { import { todoListStatusEnum } from './enums.js'; import { todoListObjectType } from './todo-list.object-type.js'; -const createTodoListDataInputType = builder.inputType('CreateTodoListData', { - fields: (t) => ({ - ownerId: t.field({ required: true, type: 'Uuid' }), - position: t.int({ required: true }), - name: t.string({ required: true }), - createdAt: t.field({ type: 'DateTime' }), - status: t.field({ type: todoListStatusEnum }), - coverPhoto: t.field({ type: fileInputInputType }), - }), -}); +const createTodoListDataInputType = builder + .inputType('CreateTodoListData', { + fields: (t) => ({ + ownerId: t.field({ required: true, type: 'Uuid' }), + position: t.int({ required: true }), + name: t.string({ required: true }), + createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), + coverPhoto: t.field({ type: fileInputInputType }), + }), + }) + .validate(createTodoList.$dataSchema); builder.mutationField('createTodoList', (t) => t.fieldWithInputPayload({ @@ -35,25 +36,28 @@ builder.mutationField('createTodoList', (t) => authorize: ['user'], resolve: async (root, { input: { data } }, context, info) => { const todoList = await createTodoList({ - data: restrictObjectNulls(data, ['createdAt']), + data, context, query: queryFromInfo({ context, info, path: ['todoList'] }), + skipValidation: true, }); return { todoList }; }, }), ); -const updateTodoListDataInputType = builder.inputType('UpdateTodoListData', { - fields: (t) => ({ - ownerId: t.field({ type: 'Uuid' }), - position: t.int(), - name: t.string(), - createdAt: t.field({ type: 'DateTime' }), - status: t.field({ type: todoListStatusEnum }), - coverPhoto: t.field({ type: fileInputInputType }), - }), -}); +const updateTodoListDataInputType = builder + .inputType('UpdateTodoListData', { + fields: (t) => ({ + ownerId: t.field({ type: 'Uuid' }), + position: t.int(), + name: t.string(), + createdAt: t.field({ type: 'DateTime' }), + status: t.field({ type: todoListStatusEnum }), + coverPhoto: t.field({ type: fileInputInputType }), + }), + }) + .validate(updateTodoList.$dataSchema); builder.mutationField('updateTodoList', (t) => t.fieldWithInputPayload({ @@ -69,14 +73,10 @@ builder.mutationField('updateTodoList', (t) => resolve: async (root, { input: { id, data } }, context, info) => { const todoList = await updateTodoList({ where: { id }, - data: restrictObjectNulls(data, [ - 'ownerId', - 'position', - 'name', - 'createdAt', - ]), + data, context, query: queryFromInfo({ context, info, path: ['todoList'] }), + skipValidation: true, }); return { todoList }; }, diff --git a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts index 6020c5448..84514f678 100644 --- a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts +++ b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts @@ -11,6 +11,7 @@ import type { PothosFieldWithInputPayloadPlugin } from './index.js'; import type { MutationWithInputPayloadOptions, OutputShapeFromFields, + PayloadFieldRef, } from './types.js'; declare global { @@ -56,10 +57,7 @@ declare global { payload: RootFieldBuilder; fieldWithInputPayload: < InputFields extends InputFieldMap, - PayloadFields extends Record< - string, - FieldRef - >, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, Args extends InputFieldMap = Record, @@ -79,7 +77,7 @@ declare global { ShapeFromTypeParam< Types, ObjectRef>, - false + Types['DefaultFieldNullability'] > >; } diff --git a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts index 1a355a40b..12ae4225e 100644 --- a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts +++ b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts @@ -8,6 +8,8 @@ import { import { capitalizeString } from '@src/utils/string.js'; +import type { PayloadFieldRef } from './types.js'; + const rootBuilderProto = RootFieldBuilder.prototype as PothosSchemaTypes.RootFieldBuilder< SchemaTypes, @@ -27,11 +29,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ // expose all fields of payload by default const payloadFields = (): Record< string, - FieldRef + PayloadFieldRef > => { for (const key of Object.keys(payload)) { payload[key].onFirstUse((cfg) => { - if (cfg.kind === 'Object') { + if (cfg.kind === 'Object' && !cfg.resolve) { cfg.resolve = (parent) => (parent as Record)[key] as Readonly; } @@ -56,9 +58,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ type: payloadRef, nullable: false, ...fieldOptions, - } as never); + } as never) as FieldRef; - fieldRef.onFirstUse((config) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fieldRef.onFirstUse((config: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const capitalizedName = capitalizeString(config.name); const inputName = `${capitalizedName}Input`; const payloadName = `${capitalizedName}Payload`; @@ -66,6 +70,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ if (inputRef) { inputRef.name = inputName; this.builder.inputType(inputRef, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Input type for ${config.name} mutation`, fields: () => input, }); @@ -74,6 +79,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ payloadRef.name = payloadName; this.builder.objectType(payloadRef, { name: payloadName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Payload type for ${config.name} mutation`, fields: payloadFields, }); diff --git a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts index bf2e7d518..90b3586cd 100644 --- a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts +++ b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts @@ -12,6 +12,11 @@ import type { SchemaTypes, } from '@pothos/core'; +export type PayloadFieldRef = Omit< + FieldRef, + 'validate' +>; + export type OutputShapeFromFields = NullableToOptional<{ [K in keyof Fields]: Fields[K] extends GenericFieldRef ? T : never; @@ -23,7 +28,7 @@ export type MutationWithInputPayloadOptions< Kind extends FieldKind, Args extends InputFieldMap, InputFields extends InputFieldMap, - PayloadFields extends Record>, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, > = Omit< diff --git a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts index 1cf274920..861d4857b 100644 --- a/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts +++ b/examples/todo-with-auth0/apps/backend/src/plugins/graphql/builder.ts @@ -3,6 +3,7 @@ import PrismaPlugin from '@pothos/plugin-prisma'; import RelayPlugin from '@pothos/plugin-relay'; import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'; import TracingPlugin, { isRootField } from '@pothos/plugin-tracing'; +import ValidationPlugin from '@pothos/plugin-validation'; import { createSentryWrapper } from '@pothos/tracing-sentry'; import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; @@ -57,6 +58,7 @@ export const builder = new SchemaBuilder<{ pothosStripQueryMutationPlugin, RelayPlugin, SimpleObjectsPlugin, + ValidationPlugin, ], prisma: { client: prisma, diff --git a/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json b/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json index d1eb02eee..4a24aa145 100644 --- a/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json +++ b/examples/todo-with-auth0/apps/backend/src/utils/.templates-info.json @@ -9,16 +9,6 @@ "instanceData": {}, "template": "http-errors" }, - "normalize-types.ts": { - "generator": "@baseplate-dev/core-generators#node/ts-utils", - "instanceData": {}, - "template": "normalize-types" - }, - "nulls.ts": { - "generator": "@baseplate-dev/core-generators#node/ts-utils", - "instanceData": {}, - "template": "nulls" - }, "request-service-context.ts": { "generator": "@baseplate-dev/fastify-generators#core/request-service-context", "instanceData": {}, diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/define-operations.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/define-operations.ts index b571281de..9c584252c 100644 --- a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/define-operations.ts +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/define-operations.ts @@ -1,5 +1,7 @@ import type { Result } from '@prisma/client/runtime/client'; +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import { prisma } from '@src/services/prisma.js'; @@ -20,6 +22,7 @@ import type { InferFieldsOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, OperationHooks, PrismaTransaction, @@ -205,6 +208,49 @@ export async function transformFields< return { data: transformedData, hooks }; } +/** + * ========================================= + * Schema Generation Utilities + * ========================================= + */ + +/** + * Generates a Zod schema for create operations from field definitions. + * + * Extracts the Zod schema from each field definition and combines them + * into a single object schema. This schema can be used for validation + * in GraphQL resolvers, REST endpoints, tRPC procedures, or OpenAPI documentation. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to extract schemas from + * @returns Zod object schema with all fields required + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }; + * + * const schema = generateCreateSchema(fields); + * // schema is z.object({ name: z.string(), email: z.string().email() }) + * + * // Use for validation + * const validated = schema.parse({ name: 'John', email: 'john@example.com' }); + * ``` + */ +export function generateCreateSchema< + TFields extends Record, +>(fields: TFields): InferInputSchema { + const shape = Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.schema]), + ) as { + [K in keyof TFields]: TFields[K]['schema']; + }; + + return z.object(shape) as InferInputSchema; +} + /** * ========================================= * Create Operation @@ -273,6 +319,9 @@ export interface CreateOperationConfig< > >; + /** + * Optional hooks for the operation + */ hooks?: OperationHooks>; } @@ -294,8 +343,22 @@ export interface CreateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type CreateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: CreateOperationInput, +) => Promise>) & { + $dataSchema: InferInputSchema; +}; + /** * Defines a type-safe create operation for a Prisma model. * @@ -349,14 +412,17 @@ export function defineCreateOperation< >, >( config: CreateOperationConfig, -): >( - input: CreateOperationInput, -) => Promise> { - return async >({ +): CreateOperationFunction { + const dataSchema = generateCreateSchema(config.fields); + + const createOperation = async >({ data, query, context, - }: CreateOperationInput) => { + skipValidation, + }: CreateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -364,6 +430,9 @@ export function defineCreateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation ? data : dataSchema.parse(data); + const baseOperationContext: OperationContext< GetPayload, { hasResult: false } @@ -376,20 +445,20 @@ export function defineCreateOperation< // Authorization if (config.authorize) { - await config.authorize(data, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } // Step 1: Transform fields (OUTSIDE TRANSACTION) const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(config.fields, data, { + transformFields(config.fields, validatedData, { operation: 'create', serviceContext: context, allowOptionalFields: false, loadExisting: () => Promise.resolve(undefined), }), config.prepareComputedFields - ? config.prepareComputedFields(data, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -451,6 +520,8 @@ export function defineCreateOperation< return result as GetPayload; }); }; + createOperation.$dataSchema = dataSchema; + return createOperation; } /** @@ -549,8 +620,23 @@ export interface UpdateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type UpdateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: UpdateOperationInput, +) => Promise>) & { + $dataSchema: z.ZodObject<{ + [k in keyof TFields]: z.ZodOptional; + }>; +}; /** * Defines a type-safe update operation for a Prisma model. * @@ -605,15 +691,18 @@ export function defineUpdateOperation< >, >( config: UpdateOperationConfig, -): >( - input: UpdateOperationInput, -) => Promise> { - return async >({ +): UpdateOperationFunction { + const dataSchema = generateCreateSchema(config.fields).partial(); + + const updateOperation = async >({ where, data: inputData, query, context, - }: UpdateOperationInput) => { + skipValidation, + }: UpdateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -621,6 +710,11 @@ export function defineUpdateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation + ? inputData + : dataSchema.parse(inputData); + let existingItem: GetPayload | undefined; const delegate = makeGenericPrismaDelegate(prisma, config.model); @@ -644,27 +738,31 @@ export function defineUpdateOperation< }; // Authorization if (config.authorize) { - await config.authorize(inputData, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } - // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Step 1: Transform fields (outside transaction) // Only transform fields provided in input const fieldsToTransform = Object.fromEntries( - Object.entries(config.fields).filter(([key]) => key in inputData), + Object.entries(config.fields).filter(([key]) => key in validatedData), ) as TFields; const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(fieldsToTransform, inputData as InferInput, { - operation: 'update', - serviceContext: context, - allowOptionalFields: true, - loadExisting: baseOperationContext.loadExisting as () => Promise< - Record - >, - }), + transformFields( + fieldsToTransform, + validatedData as InferInput, + { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }, + ), config.prepareComputedFields - ? config.prepareComputedFields(inputData, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -728,6 +826,8 @@ export function defineUpdateOperation< return result as GetPayload; }); }; + updateOperation.$dataSchema = generateCreateSchema(config.fields).partial(); + return updateOperation; } /** diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/field-definitions.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/field-definitions.ts index ab87233ae..f12c65e00 100644 --- a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/field-definitions.ts +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/field-definitions.ts @@ -17,47 +17,75 @@ import type { InferFieldsCreateOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, TransactionalOperationContext, } from './types.js'; -import { invokeHooks, transformFields } from './define-operations.js'; +import { + generateCreateSchema, + invokeHooks, + transformFields, +} from './define-operations.js'; import { makeGenericPrismaDelegate } from './prisma-utils.js'; /** - * Create a simple scalar field with validation only + * Create a simple scalar field with validation and optional transformation * * This helper creates a field definition that validates input using a Zod schema. - * The validated value is passed through unchanged to the transform step. + * Optionally, you can provide a transform function to convert the validated value + * into a different type for Prisma operations. * * For relation fields (e.g., `userId`), use this helper to validate the ID, * then use relation helpers in the transform step to create Prisma connect/disconnect objects. * + * @template TSchema - The Zod schema type for validation + * @template TTransformed - The output type after transformation (defaults to schema output) * @param schema - Zod schema for validation + * @param options - Optional configuration + * @param options.transform - Function to transform the validated value * @returns Field definition * * @example * ```typescript + * // Simple validation * const fields = { * title: scalarField(z.string()), * ownerId: scalarField(z.string()), // Validated as string * }; * - * // In transform, convert IDs to relations: - * transform: (data) => ({ - * title: data.title, - * owner: relation.required(data.ownerId), - * }) + * // With transformation + * const fields = { + * email: scalarField( + * z.string().email(), + * { transform: (email) => email.toLowerCase() } + * ), + * createdAt: scalarField( + * z.string().datetime(), + * { transform: (dateStr) => new Date(dateStr) } + * ), + * }; * ``` */ -export function scalarField( +export function scalarField< + TSchema extends z.ZodSchema, + TTransformed = z.output, +>( schema: TSchema, -): FieldDefinition, z.output, z.output> { + options?: { + transform?: (value: z.output) => TTransformed; + }, +): FieldDefinition { return { + schema, processInput: (value) => { - const validated = schema.parse(value) as z.output; + // Apply transform if provided + const transformed = options?.transform + ? options.transform(value) + : (value as TTransformed); + return { - data: { create: validated, update: validated }, + data: { create: transformed, update: transformed }, }; }, }; @@ -190,15 +218,13 @@ export interface NestedOneToOneFieldConfig< * This helper creates a field definition for managing one-to-one nested relationships. * It handles nested field validation, transformation, and supports both create and update operations. * - * For create operations: - * - Returns nested create data if input is provided - * - Returns undefined if input is not provided + * The nested entity is created/updated via afterExecute hooks, allowing it to reference + * the parent entity after it has been created. * - * For update operations: - * - Returns upsert if input has a unique identifier (via getWhereUnique) - * - Returns create if input doesn't have a unique identifier - * - Deletes the relation if input is null (requires deleteRelation) - * - Returns undefined if input is not provided (no change) + * Behavior: + * - **Provided value**: Upserts the nested entity (creates if new, updates if exists) + * - **null**: Deletes the nested entity (update only) + * - **undefined**: No change to nested entity * * @param config - Configuration object * @returns Field definition @@ -207,18 +233,24 @@ export interface NestedOneToOneFieldConfig< * ```typescript * const fields = { * userProfile: nestedOneToOneField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userProfile', + * relationName: 'user', * fields: { * bio: scalarField(z.string()), * avatar: fileField(avatarFileCategory), * }, + * getWhereUnique: (parent) => ({ userId: parent.id }), * buildData: (data) => ({ - * bio: data.bio, - * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * create: { + * bio: data.create.bio, + * avatar: data.create.avatar ? { connect: { id: data.create.avatar } } : undefined, + * }, + * update: { + * bio: data.update.bio, + * avatar: data.update.avatar ? { connect: { id: data.update.avatar } } : undefined, + * }, * }), - * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, - * deleteRelation: async () => { - * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); - * }, * }), * }; * ``` @@ -236,11 +268,12 @@ export function nestedOneToOneField< TFields >, ): FieldDefinition< - InferInput | null | undefined, + z.ZodOptional>>, undefined, undefined | { delete: true } > { return { + schema: generateCreateSchema(config.fields).nullish(), processInput: async (value, processCtx) => { // Handle null - delete the relation if (value === null) { @@ -542,7 +575,7 @@ export function nestedOneToManyField< TFields >, ): FieldDefinition< - InferInput[] | undefined, + z.ZodOptional>>, undefined, undefined | { deleteMany: Record } > { @@ -560,6 +593,7 @@ export function nestedOneToManyField< }; return { + schema: generateCreateSchema(config.fields).array().optional(), processInput: async (value, processCtx) => { const { serviceContext, loadExisting } = processCtx; diff --git a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/types.ts b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/types.ts index aba7c5e6b..b37885d95 100644 --- a/examples/todo-with-auth0/apps/backend/src/utils/data-operations/types.ts +++ b/examples/todo-with-auth0/apps/backend/src/utils/data-operations/types.ts @@ -1,4 +1,5 @@ import type { ITXClientDenyList } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; import type { PrismaClient } from '@src/generated/prisma/client.js'; @@ -190,17 +191,18 @@ export interface FieldTransformResult { * Field definition for validating and transforming input values. * * A field definition specifies how to process a single input field: - * - Validate the input value + * - Validate the input value using a Zod schema * - Transform it into Prisma-compatible create/update data * - Optionally attach hooks for side effects * - * @template TInput - The expected input type + * @template TInputSchema - The Zod schema type for validation * @template TCreateOutput - Output type for create operations * @template TUpdateOutput - Output type for update operations * * @example * ```typescript - * const nameField: FieldDefinition = { + * const nameField: FieldDefinition = { + * zodSchema: z.string().min(1), * processInput: (value, ctx) => { * const validated = z.string().min(1).parse(value); * return { @@ -213,16 +215,30 @@ export interface FieldTransformResult { * }; * ``` */ -export interface FieldDefinition { +export interface FieldDefinition< + TInputSchema extends z.ZodSchema, + TCreateOutput, + TUpdateOutput, +> { + /** + * The Zod schema for validating this field's input. + * This schema can be extracted and reused for validation in other contexts + * (e.g., GraphQL mutations, REST endpoints, tRPC procedures). + */ + schema: TInputSchema; + /** * Processes and transforms an input value. * - * @param value - The input value to process + * Note: Validation happens at the operation level (defineCreateOperation/defineUpdateOperation), + * not at the field level. This function receives already-validated input. + * + * @param value - The validated input value to process * @param ctx - Context about the operation * @returns Transformed data and optional hooks */ processInput: ( - value: TInput, + value: z.output, ctx: FieldContext, ) => | Promise> @@ -238,14 +254,6 @@ export type AnyFieldDefinition = FieldDefinition; * ========================================= */ -/** Extracts keys from T where the value type does not include undefined */ -type RequiredKeys = { - [P in keyof T]: T[P] extends Exclude ? P : never; -}[keyof T]; - -/** Makes properties with undefined values optional, keeps others required */ -type OptionalForUndefinedKeys = Partial & Pick>; - /** Identity type that expands type aliases for better IDE tooltips */ type Identity = T extends object ? {} & { @@ -253,12 +261,44 @@ type Identity = T extends object } : T; +/** + * Infers the input schema from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's Zod schema type + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type InputSchema = InferInputSchema; + * // { name: z.ZodString; email?: z.ZodString | undefined } + * ``` + */ +export type InferInputSchema< + TFields extends Record, +> = z.ZodObject<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInputSchema, + any, + any + > + ? TInputSchema + : never; +}>; + /** * Infers the input type from a record of field definitions. * * Creates an object type where: * - Each key corresponds to a field name - * - Each value type is the field's input type + * - Each value type is the field's Zod schema type * - Fields accepting undefined become optional properties * * @template TFields - Record of field definitions @@ -275,17 +315,7 @@ type Identity = T extends object * ``` */ export type InferInput> = - Identity< - OptionalForUndefinedKeys<{ - [K in keyof TFields]: TFields[K] extends FieldDefinition< - infer TInput, - any, - any - > - ? TInput - : never; - }> - >; + z.output>; /** * Infers the output type (create and update) from a single field definition. diff --git a/examples/todo-with-auth0/apps/backend/src/utils/normalize-types.ts b/examples/todo-with-auth0/apps/backend/src/utils/normalize-types.ts deleted file mode 100644 index 79ed1f313..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/normalize-types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Typescript hack to force IDEs to show raw object - * type without additional typing that we have added - */ -export type NormalizeTypes = - T extends Record - ? { - [K in keyof T]: T[K]; - } - : T; diff --git a/examples/todo-with-auth0/apps/backend/src/utils/nulls.ts b/examples/todo-with-auth0/apps/backend/src/utils/nulls.ts deleted file mode 100644 index a80df86cb..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/nulls.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { NormalizeTypes } from './normalize-types.js'; - -/** - * Restricts an object from having null in certain fields. This - * is useful when you want to enforce certain fields are not null - * when coming from GraphQL which only has the option of - * defining null | undefined fields. - * - * @param object Object to validate - * @param restrictedKeys Prevents these keys from being set to null - * @returns A newly typed object whose restrictedKeys are not null - */ -export function restrictObjectNulls< - ObjectType extends Record, - NonNullKeys extends keyof ObjectType, ->( - object: ObjectType, - nonNullKeys: NonNullKeys[], -): NormalizeTypes< - ObjectType & { [K in NonNullKeys]: Exclude } -> { - const nullKey = nonNullKeys.find((key) => object[key] === null); - if (nullKey) { - throw new Error(`${nullKey.toString()} cannot be null`); - } - return object as NormalizeTypes< - ObjectType & { - [K in NonNullKeys]: Exclude; - } - >; -} diff --git a/examples/todo-with-auth0/apps/backend/src/utils/nulls.unit.test.ts b/examples/todo-with-auth0/apps/backend/src/utils/nulls.unit.test.ts deleted file mode 100644 index 49424c5e2..000000000 --- a/examples/todo-with-auth0/apps/backend/src/utils/nulls.unit.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { restrictObjectNulls } from './nulls.js'; - -describe('restrictObjectNulls', () => { - it('should allow an empty object through', () => { - const object = restrictObjectNulls({}, []); - expect(object).toEqual({}); - }); - - it('should allow a simple object through', () => { - const object: { test: string | null; field2: string } = { - test: 'test', - field2: 'field2', - }; - const result = restrictObjectNulls(object, ['test']); - expect(result).toEqual(object); - }); - - it('should reject an object with a restricted null field', () => { - const object = { test: null, field2: 'field2' }; - expect(() => restrictObjectNulls(object, ['test'])).toThrow(); - }); -}); diff --git a/examples/todo-with-auth0/pnpm-lock.yaml b/examples/todo-with-auth0/pnpm-lock.yaml index 9fbb77aaa..8376b6259 100644 --- a/examples/todo-with-auth0/pnpm-lock.yaml +++ b/examples/todo-with-auth0/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: '@pothos/plugin-tracing': specifier: 1.1.0 version: 1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) + '@pothos/plugin-validation': + specifier: 4.2.0 + version: 4.2.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) '@pothos/tracing-sentry': specifier: 1.1.1 version: 1.1.1(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(@sentry/node@9.17.0)(graphql@16.11.0) @@ -2127,6 +2130,12 @@ packages: '@pothos/core': '*' graphql: '>=16.6.0' + '@pothos/plugin-validation@4.2.0': + resolution: {integrity: sha512-iqqlNzHGhTbWIf6dfouMjJfh72gOQEft7gp0Bl0vhW8WNWuOwVmrXrXtF+Lu3hyZuBY7WfQ44jQ41hXjYmqx0g==} + peerDependencies: + '@pothos/core': '*' + graphql: ^16.10.0 + '@pothos/tracing-sentry@1.1.1': resolution: {integrity: sha512-r9loI0Fc8Qax1dNxM/IuWj+66ApyyE7WamOIdXNC3uKNqIgx71diXOY/74TBSctRabra+z+0bXsQyI3voWcftw==} peerDependencies: @@ -9435,6 +9444,11 @@ snapshots: '@pothos/core': 4.10.0(graphql@16.11.0) graphql: 16.11.0 + '@pothos/plugin-validation@4.2.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)': + dependencies: + '@pothos/core': 4.10.0(graphql@16.11.0) + graphql: 16.11.0 + '@pothos/tracing-sentry@1.1.1(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(@sentry/node@9.17.0)(graphql@16.11.0)': dependencies: '@pothos/core': 4.10.0(graphql@16.11.0) diff --git a/packages/fastify-generators/src/constants/fastify-packages.ts b/packages/fastify-generators/src/constants/fastify-packages.ts index 2c5316d54..2c56fd176 100644 --- a/packages/fastify-generators/src/constants/fastify-packages.ts +++ b/packages/fastify-generators/src/constants/fastify-packages.ts @@ -28,6 +28,7 @@ export const FASTIFY_PACKAGES = { '@pothos/plugin-simple-objects': '4.1.3', '@pothos/plugin-relay': '4.6.2', '@pothos/plugin-prisma': '4.12.0', + '@pothos/plugin-validation': '4.2.0', 'graphql-scalars': '1.23.0', '@graphql-yoga/redis-event-target': '2.0.0', diff --git a/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts b/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts index 1a9020888..33b7d33fd 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos-prisma-crud-mutation/pothos-prisma-crud-mutation.generator.ts @@ -4,6 +4,7 @@ import { tsCodeFragment, TsCodeUtils, tsImportBuilder, + tsTemplate, tsUtilsImportsProvider, } from '@baseplate-dev/core-generators'; import { @@ -27,6 +28,7 @@ import { contextKind, prismaQueryKind, prismaWhereUniqueInputKind, + skipValidationKind, } from '#src/types/service-dto-kinds.js'; import { lowerCaseFirst } from '#src/utils/case.js'; import { @@ -107,6 +109,10 @@ function handleInjectedArg( }; } + case skipValidationKind: { + return { fragment: tsTemplate`true`, requirements: [] }; + } + default: { throw new Error(`Unknown injected argument kind: ${arg.kind.name}`); } diff --git a/packages/fastify-generators/src/generators/pothos/pothos/extractor.json b/packages/fastify-generators/src/generators/pothos/pothos/extractor.json index f11cd1e2e..4e643370a 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/extractor.json +++ b/packages/fastify-generators/src/generators/pothos/pothos/extractor.json @@ -51,6 +51,7 @@ } }, "pathRootRelativePath": "{src-root}/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts", + "referencedGeneratorTemplates": ["field-with-input-types"], "sourceFile": "src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts", "variables": {} }, diff --git a/packages/fastify-generators/src/generators/pothos/pothos/generated/typed-templates.ts b/packages/fastify-generators/src/generators/pothos/pothos/generated/typed-templates.ts index 456c90559..019c1102d 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/generated/typed-templates.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/generated/typed-templates.ts @@ -59,6 +59,7 @@ const fieldWithInputSchemaBuilder = createTsTemplateFile({ group: 'field-with-input-payload', importMapProviders: { tsUtilsImports: tsUtilsImportsProvider }, name: 'field-with-input-schema-builder', + referencedGeneratorTemplates: { fieldWithInputTypes: {} }, source: { path: path.join( import.meta.dirname, diff --git a/packages/fastify-generators/src/generators/pothos/pothos/pothos.generator.ts b/packages/fastify-generators/src/generators/pothos/pothos/pothos.generator.ts index af608f70b..7251728e9 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/pothos.generator.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/pothos.generator.ts @@ -145,6 +145,7 @@ export const pothosGenerator = createGenerator({ '@pothos/core', '@pothos/plugin-simple-objects', '@pothos/plugin-relay', + '@pothos/plugin-validation', ]), }), main: createGeneratorTask({ @@ -221,6 +222,12 @@ export const pothosGenerator = createGenerator({ .default('RelayPlugin') .from('@pothos/plugin-relay'), ), + validationPlugin: tsCodeFragment( + `ValidationPlugin`, + tsImportBuilder() + .default('ValidationPlugin') + .from('@pothos/plugin-validation'), + ), }; const schemaOptionsFragment = TsCodeUtils.mergeFragmentsAsObject({ diff --git a/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts b/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts index 8ff40053d..7386ce03e 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts @@ -4,6 +4,7 @@ import type { PothosFieldWithInputPayloadPlugin } from '$fieldWithInputPlugin'; import type { MutationWithInputPayloadOptions, OutputShapeFromFields, + PayloadFieldRef, } from '$fieldWithInputTypes'; import type { FieldKind, @@ -57,10 +58,7 @@ declare global { payload: RootFieldBuilder; fieldWithInputPayload: < InputFields extends InputFieldMap, - PayloadFields extends Record< - string, - FieldRef - >, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, Args extends InputFieldMap = Record, @@ -80,7 +78,7 @@ declare global { ShapeFromTypeParam< Types, ObjectRef>, - false + Types['DefaultFieldNullability'] > >; } diff --git a/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts b/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts index bfd63cb8e..67dfc539d 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts @@ -1,5 +1,6 @@ // @ts-nocheck +import type { PayloadFieldRef } from '$fieldWithInputTypes'; import type { FieldRef, SchemaTypes } from '@pothos/core'; import { capitalizeString } from '%tsUtilsImports'; @@ -28,11 +29,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ // expose all fields of payload by default const payloadFields = (): Record< string, - FieldRef + PayloadFieldRef > => { for (const key of Object.keys(payload)) { payload[key].onFirstUse((cfg) => { - if (cfg.kind === 'Object') { + if (cfg.kind === 'Object' && !cfg.resolve) { cfg.resolve = (parent) => (parent as Record)[key] as Readonly; } @@ -57,9 +58,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ type: payloadRef, nullable: false, ...fieldOptions, - } as never); + } as never) as FieldRef; - fieldRef.onFirstUse((config) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fieldRef.onFirstUse((config: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const capitalizedName = capitalizeString(config.name); const inputName = `${capitalizedName}Input`; const payloadName = `${capitalizedName}Payload`; @@ -67,6 +70,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ if (inputRef) { inputRef.name = inputName; this.builder.inputType(inputRef, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Input type for ${config.name} mutation`, fields: () => input, }); @@ -75,6 +79,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ payloadRef.name = payloadName; this.builder.objectType(payloadRef, { name: payloadName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Payload type for ${config.name} mutation`, fields: payloadFields, }); diff --git a/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts b/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts index 85f9d4c2e..23df37eb3 100644 --- a/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts +++ b/packages/fastify-generators/src/generators/pothos/pothos/templates/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts @@ -14,6 +14,11 @@ import type { SchemaTypes, } from '@pothos/core'; +export type PayloadFieldRef = Omit< + FieldRef, + 'validate' +>; + export type OutputShapeFromFields = NullableToOptional<{ [K in keyof Fields]: Fields[K] extends GenericFieldRef ? T : never; @@ -25,7 +30,7 @@ export type MutationWithInputPayloadOptions< Kind extends FieldKind, Args extends InputFieldMap, InputFields extends InputFieldMap, - PayloadFields extends Record>, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, > = Omit< diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/define-operations.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/define-operations.ts index 9a033ee57..b73321f9a 100644 --- a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/define-operations.ts +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/define-operations.ts @@ -15,6 +15,7 @@ import type { InferFieldsOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, OperationHooks, PrismaTransaction, @@ -27,6 +28,7 @@ import type { Result } from '@prisma/client/runtime/client'; import { makeGenericPrismaDelegate } from '$prismaUtils'; import { NotFoundError } from '%errorHandlerServiceImports'; import { prisma } from '%prismaImports'; +import { z } from 'zod'; /** * Invokes an array of hooks with the provided context. @@ -204,6 +206,49 @@ export async function transformFields< return { data: transformedData, hooks }; } +/** + * ========================================= + * Schema Generation Utilities + * ========================================= + */ + +/** + * Generates a Zod schema for create operations from field definitions. + * + * Extracts the Zod schema from each field definition and combines them + * into a single object schema. This schema can be used for validation + * in GraphQL resolvers, REST endpoints, tRPC procedures, or OpenAPI documentation. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to extract schemas from + * @returns Zod object schema with all fields required + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }; + * + * const schema = generateCreateSchema(fields); + * // schema is z.object({ name: z.string(), email: z.string().email() }) + * + * // Use for validation + * const validated = schema.parse({ name: 'John', email: 'john@example.com' }); + * ``` + */ +export function generateCreateSchema< + TFields extends Record, +>(fields: TFields): InferInputSchema { + const shape = Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.schema]), + ) as { + [K in keyof TFields]: TFields[K]['schema']; + }; + + return z.object(shape) as InferInputSchema; +} + /** * ========================================= * Create Operation @@ -272,6 +317,9 @@ export interface CreateOperationConfig< > >; + /** + * Optional hooks for the operation + */ hooks?: OperationHooks>; } @@ -293,8 +341,22 @@ export interface CreateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type CreateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: CreateOperationInput, +) => Promise>) & { + $dataSchema: InferInputSchema; +}; + /** * Defines a type-safe create operation for a Prisma model. * @@ -348,14 +410,17 @@ export function defineCreateOperation< >, >( config: CreateOperationConfig, -): >( - input: CreateOperationInput, -) => Promise> { - return async >({ +): CreateOperationFunction { + const dataSchema = generateCreateSchema(config.fields); + + const createOperation = async >({ data, query, context, - }: CreateOperationInput) => { + skipValidation, + }: CreateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -363,6 +428,9 @@ export function defineCreateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation ? data : dataSchema.parse(data); + const baseOperationContext: OperationContext< GetPayload, { hasResult: false } @@ -375,20 +443,20 @@ export function defineCreateOperation< // Authorization if (config.authorize) { - await config.authorize(data, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } // Step 1: Transform fields (OUTSIDE TRANSACTION) const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(config.fields, data, { + transformFields(config.fields, validatedData, { operation: 'create', serviceContext: context, allowOptionalFields: false, loadExisting: () => Promise.resolve(undefined), }), config.prepareComputedFields - ? config.prepareComputedFields(data, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -450,6 +518,8 @@ export function defineCreateOperation< return result as GetPayload; }); }; + createOperation.$dataSchema = dataSchema; + return createOperation; } /** @@ -548,8 +618,23 @@ export interface UpdateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type UpdateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: UpdateOperationInput, +) => Promise>) & { + $dataSchema: z.ZodObject<{ + [k in keyof TFields]: z.ZodOptional; + }>; +}; /** * Defines a type-safe update operation for a Prisma model. * @@ -604,15 +689,18 @@ export function defineUpdateOperation< >, >( config: UpdateOperationConfig, -): >( - input: UpdateOperationInput, -) => Promise> { - return async >({ +): UpdateOperationFunction { + const dataSchema = generateCreateSchema(config.fields).partial(); + + const updateOperation = async >({ where, data: inputData, query, context, - }: UpdateOperationInput) => { + skipValidation, + }: UpdateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -620,6 +708,11 @@ export function defineUpdateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation + ? inputData + : dataSchema.parse(inputData); + let existingItem: GetPayload | undefined; const delegate = makeGenericPrismaDelegate(prisma, config.model); @@ -643,27 +736,31 @@ export function defineUpdateOperation< }; // Authorization if (config.authorize) { - await config.authorize(inputData, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } - // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Step 1: Transform fields (outside transaction) // Only transform fields provided in input const fieldsToTransform = Object.fromEntries( - Object.entries(config.fields).filter(([key]) => key in inputData), + Object.entries(config.fields).filter(([key]) => key in validatedData), ) as TFields; const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(fieldsToTransform, inputData as InferInput, { - operation: 'update', - serviceContext: context, - allowOptionalFields: true, - loadExisting: baseOperationContext.loadExisting as () => Promise< - Record - >, - }), + transformFields( + fieldsToTransform, + validatedData as InferInput, + { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }, + ), config.prepareComputedFields - ? config.prepareComputedFields(inputData, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -727,6 +824,8 @@ export function defineUpdateOperation< return result as GetPayload; }); }; + updateOperation.$dataSchema = generateCreateSchema(config.fields).partial(); + return updateOperation; } /** diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/field-definitions.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/field-definitions.ts index 34b6e1f7f..818c5b2fb 100644 --- a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/field-definitions.ts +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/field-definitions.ts @@ -14,50 +14,78 @@ import type { InferFieldsCreateOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, TransactionalOperationContext, } from '$types'; import type { Payload } from '@prisma/client/runtime/client'; import type { z } from 'zod'; -import { invokeHooks, transformFields } from '$defineOperations'; +import { + generateCreateSchema, + invokeHooks, + transformFields, +} from '$defineOperations'; import { makeGenericPrismaDelegate } from '$prismaUtils'; import { prisma } from '%prismaImports'; /** - * Create a simple scalar field with validation only + * Create a simple scalar field with validation and optional transformation * * This helper creates a field definition that validates input using a Zod schema. - * The validated value is passed through unchanged to the transform step. + * Optionally, you can provide a transform function to convert the validated value + * into a different type for Prisma operations. * * For relation fields (e.g., `userId`), use this helper to validate the ID, * then use relation helpers in the transform step to create Prisma connect/disconnect objects. * + * @template TSchema - The Zod schema type for validation + * @template TTransformed - The output type after transformation (defaults to schema output) * @param schema - Zod schema for validation + * @param options - Optional configuration + * @param options.transform - Function to transform the validated value * @returns Field definition * * @example * ```typescript + * // Simple validation * const fields = { * title: scalarField(z.string()), * ownerId: scalarField(z.string()), // Validated as string * }; * - * // In transform, convert IDs to relations: - * transform: (data) => ({ - * title: data.title, - * owner: relation.required(data.ownerId), - * }) + * // With transformation + * const fields = { + * email: scalarField( + * z.string().email(), + * { transform: (email) => email.toLowerCase() } + * ), + * createdAt: scalarField( + * z.string().datetime(), + * { transform: (dateStr) => new Date(dateStr) } + * ), + * }; * ``` */ -export function scalarField( +export function scalarField< + TSchema extends z.ZodSchema, + TTransformed = z.output, +>( schema: TSchema, -): FieldDefinition, z.output, z.output> { + options?: { + transform?: (value: z.output) => TTransformed; + }, +): FieldDefinition { return { + schema, processInput: (value) => { - const validated = schema.parse(value) as z.output; + // Apply transform if provided + const transformed = options?.transform + ? options.transform(value) + : (value as TTransformed); + return { - data: { create: validated, update: validated }, + data: { create: transformed, update: transformed }, }; }, }; @@ -190,15 +218,13 @@ export interface NestedOneToOneFieldConfig< * This helper creates a field definition for managing one-to-one nested relationships. * It handles nested field validation, transformation, and supports both create and update operations. * - * For create operations: - * - Returns nested create data if input is provided - * - Returns undefined if input is not provided + * The nested entity is created/updated via afterExecute hooks, allowing it to reference + * the parent entity after it has been created. * - * For update operations: - * - Returns upsert if input has a unique identifier (via getWhereUnique) - * - Returns create if input doesn't have a unique identifier - * - Deletes the relation if input is null (requires deleteRelation) - * - Returns undefined if input is not provided (no change) + * Behavior: + * - **Provided value**: Upserts the nested entity (creates if new, updates if exists) + * - **null**: Deletes the nested entity (update only) + * - **undefined**: No change to nested entity * * @param config - Configuration object * @returns Field definition @@ -207,18 +233,24 @@ export interface NestedOneToOneFieldConfig< * ```typescript * const fields = { * userProfile: nestedOneToOneField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userProfile', + * relationName: 'user', * fields: { * bio: scalarField(z.string()), * avatar: fileField(avatarFileCategory), * }, + * getWhereUnique: (parent) => ({ userId: parent.id }), * buildData: (data) => ({ - * bio: data.bio, - * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * create: { + * bio: data.create.bio, + * avatar: data.create.avatar ? { connect: { id: data.create.avatar } } : undefined, + * }, + * update: { + * bio: data.update.bio, + * avatar: data.update.avatar ? { connect: { id: data.update.avatar } } : undefined, + * }, * }), - * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, - * deleteRelation: async () => { - * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); - * }, * }), * }; * ``` @@ -236,11 +268,12 @@ export function nestedOneToOneField< TFields >, ): FieldDefinition< - InferInput | null | undefined, + z.ZodOptional>>, undefined, undefined | { delete: true } > { return { + schema: generateCreateSchema(config.fields).nullish(), processInput: async (value, processCtx) => { // Handle null - delete the relation if (value === null) { @@ -542,7 +575,7 @@ export function nestedOneToManyField< TFields >, ): FieldDefinition< - InferInput[] | undefined, + z.ZodOptional>>, undefined, undefined | { deleteMany: Record } > { @@ -560,6 +593,7 @@ export function nestedOneToManyField< }; return { + schema: generateCreateSchema(config.fields).array().optional(), processInput: async (value, processCtx) => { const { serviceContext, loadExisting } = processCtx; diff --git a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/types.ts b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/types.ts index 739e637c5..b0f25adc3 100644 --- a/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/types.ts +++ b/packages/fastify-generators/src/generators/prisma/data-utils/templates/src/utils/data-operations/types.ts @@ -3,6 +3,7 @@ import type { PrismaClient } from '%prismaGeneratedImports'; import type { ServiceContext } from '%serviceContextImports'; import type { ITXClientDenyList } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; /** * Prisma transaction client type for data operations. @@ -190,17 +191,18 @@ export interface FieldTransformResult { * Field definition for validating and transforming input values. * * A field definition specifies how to process a single input field: - * - Validate the input value + * - Validate the input value using a Zod schema * - Transform it into Prisma-compatible create/update data * - Optionally attach hooks for side effects * - * @template TInput - The expected input type + * @template TInputSchema - The Zod schema type for validation * @template TCreateOutput - Output type for create operations * @template TUpdateOutput - Output type for update operations * * @example * ```typescript - * const nameField: FieldDefinition = { + * const nameField: FieldDefinition = { + * zodSchema: z.string().min(1), * processInput: (value, ctx) => { * const validated = z.string().min(1).parse(value); * return { @@ -213,16 +215,30 @@ export interface FieldTransformResult { * }; * ``` */ -export interface FieldDefinition { +export interface FieldDefinition< + TInputSchema extends z.ZodSchema, + TCreateOutput, + TUpdateOutput, +> { + /** + * The Zod schema for validating this field's input. + * This schema can be extracted and reused for validation in other contexts + * (e.g., GraphQL mutations, REST endpoints, tRPC procedures). + */ + schema: TInputSchema; + /** * Processes and transforms an input value. * - * @param value - The input value to process + * Note: Validation happens at the operation level (defineCreateOperation/defineUpdateOperation), + * not at the field level. This function receives already-validated input. + * + * @param value - The validated input value to process * @param ctx - Context about the operation * @returns Transformed data and optional hooks */ processInput: ( - value: TInput, + value: z.output, ctx: FieldContext, ) => | Promise> @@ -238,14 +254,6 @@ export type AnyFieldDefinition = FieldDefinition; * ========================================= */ -/** Extracts keys from T where the value type does not include undefined */ -type RequiredKeys = { - [P in keyof T]: T[P] extends Exclude ? P : never; -}[keyof T]; - -/** Makes properties with undefined values optional, keeps others required */ -type OptionalForUndefinedKeys = Partial & Pick>; - /** Identity type that expands type aliases for better IDE tooltips */ type Identity = T extends object ? {} & { @@ -253,12 +261,44 @@ type Identity = T extends object } : T; +/** + * Infers the input schema from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's Zod schema type + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type InputSchema = InferInputSchema; + * // { name: z.ZodString; email?: z.ZodString | undefined } + * ``` + */ +export type InferInputSchema< + TFields extends Record, +> = z.ZodObject<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInputSchema, + any, + any + > + ? TInputSchema + : never; +}>; + /** * Infers the input type from a record of field definitions. * * Creates an object type where: * - Each key corresponds to a field name - * - Each value type is the field's input type + * - Each value type is the field's Zod schema type * - Fields accepting undefined become optional properties * * @template TFields - Record of field definitions @@ -275,17 +315,7 @@ type Identity = T extends object * ``` */ export type InferInput> = - Identity< - OptionalForUndefinedKeys<{ - [K in keyof TFields]: TFields[K] extends FieldDefinition< - infer TInput, - any, - any - > - ? TInput - : never; - }> - >; + z.output>; /** * Infers the output type (create and update) from a single field definition. diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-create/prisma-data-create.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-create/prisma-data-create.generator.ts index 362e627e8..182a220de 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-data-create/prisma-data-create.generator.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-create/prisma-data-create.generator.ts @@ -13,7 +13,11 @@ import { import { z } from 'zod'; import { serviceFileProvider } from '#src/generators/core/index.js'; -import { contextKind, prismaQueryKind } from '#src/types/service-dto-kinds.js'; +import { + contextKind, + prismaQueryKind, + skipValidationKind, +} from '#src/types/service-dto-kinds.js'; import { createServiceOutputDtoInjectedArg, prismaToServiceOutputDto, @@ -79,7 +83,11 @@ export const prismaDataCreateGenerator = createGenerator({ create: ${createCallbackFragment}, }) `; - serviceFile.getServicePath(); + + const methodFragment = TsCodeUtils.importFragment( + name, + serviceFile.getServicePath(), + ); prismaDataService.registerMethod({ name, @@ -87,10 +95,7 @@ export const prismaDataCreateGenerator = createGenerator({ fragment: createOperation, outputMethod: { name, - referenceFragment: TsCodeUtils.importFragment( - name, - serviceFile.getServicePath(), - ), + referenceFragment: methodFragment, arguments: [ { name: 'data', @@ -99,6 +104,7 @@ export const prismaDataCreateGenerator = createGenerator({ name: `${uppercaseFirstChar(name)}Data`, fields: usedFields.map((field) => field.outputDtoField), }, + zodSchemaFragment: tsTemplate`${methodFragment}.$dataSchema`, }, createServiceOutputDtoInjectedArg({ type: 'injected', @@ -110,6 +116,11 @@ export const prismaDataCreateGenerator = createGenerator({ name: 'query', kind: prismaQueryKind, }), + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'skipValidation', + kind: skipValidationKind, + }), ], returnType: prismaToServiceOutputDto( prismaOutput.getPrismaModel(modelName), diff --git a/packages/fastify-generators/src/generators/prisma/prisma-data-update/prisma-data-update.generator.ts b/packages/fastify-generators/src/generators/prisma/prisma-data-update/prisma-data-update.generator.ts index 1925fd688..38d1cec9e 100644 --- a/packages/fastify-generators/src/generators/prisma/prisma-data-update/prisma-data-update.generator.ts +++ b/packages/fastify-generators/src/generators/prisma/prisma-data-update/prisma-data-update.generator.ts @@ -17,6 +17,7 @@ import { contextKind, prismaQueryKind, prismaWhereUniqueInputKind, + skipValidationKind, } from '#src/types/service-dto-kinds.js'; import { createServiceOutputDtoInjectedArg, @@ -83,20 +84,21 @@ export const prismaDataUpdateGenerator = createGenerator({ update: ${updateCallbackFragment}, }) `; - serviceFile.getServicePath(); const prismaModel = prismaOutput.getPrismaModel(modelName); + const methodFragment = TsCodeUtils.importFragment( + name, + serviceFile.getServicePath(), + ); + prismaDataService.registerMethod({ name, type: 'update', fragment: updateOperation, outputMethod: { name, - referenceFragment: TsCodeUtils.importFragment( - name, - serviceFile.getServicePath(), - ), + referenceFragment: methodFragment, arguments: [ createServiceOutputDtoInjectedArg({ type: 'injected', @@ -116,6 +118,7 @@ export const prismaDataUpdateGenerator = createGenerator({ isOptional: true, })), }, + zodSchemaFragment: tsTemplate`${methodFragment}.$dataSchema`, }, createServiceOutputDtoInjectedArg({ type: 'injected', @@ -127,6 +130,11 @@ export const prismaDataUpdateGenerator = createGenerator({ name: 'query', kind: prismaQueryKind, }), + createServiceOutputDtoInjectedArg({ + type: 'injected', + name: 'skipValidation', + kind: skipValidationKind, + }), ], returnType: prismaToServiceOutputDto(prismaModel, (enumName) => prismaOutput.getServiceEnum(enumName), diff --git a/packages/fastify-generators/src/types/service-dto-kinds.ts b/packages/fastify-generators/src/types/service-dto-kinds.ts index d49d80ff8..b34754122 100644 --- a/packages/fastify-generators/src/types/service-dto-kinds.ts +++ b/packages/fastify-generators/src/types/service-dto-kinds.ts @@ -84,3 +84,10 @@ export const prismaQueryKind = createServiceDtoKind('prisma-query'); export const prismaWhereUniqueInputKind = createServiceDtoKind<{ idFields: string[]; }>('prisma-where-unique-input'); + +/** + * Skip validation argument kind. + * + * Skips Zod validation if data has already been validated (avoids double validation). + */ +export const skipValidationKind = createServiceDtoKind('skip-validation'); diff --git a/packages/fastify-generators/src/types/service-output.ts b/packages/fastify-generators/src/types/service-output.ts index 54e8f0bd8..deae9ea25 100644 --- a/packages/fastify-generators/src/types/service-output.ts +++ b/packages/fastify-generators/src/types/service-output.ts @@ -48,6 +48,10 @@ export interface ServiceOutputDtoNestedFieldWithoutPrisma nestedType: ServiceOutputDto; typescriptType?: TsCodeFragment; schemaFieldName?: string; + /** + * Fragment that references the Zod schema for the nested field. + */ + zodSchemaFragment?: TsCodeFragment; } export interface ServiceOutputDtoNestedFieldWithPrisma diff --git a/packages/fastify-generators/src/writers/pothos/input-types.ts b/packages/fastify-generators/src/writers/pothos/input-types.ts index fbe1191d3..c0a493db0 100644 --- a/packages/fastify-generators/src/writers/pothos/input-types.ts +++ b/packages/fastify-generators/src/writers/pothos/input-types.ts @@ -58,6 +58,7 @@ export function writePothosInputDefinitionFromDtoFields( fields: ServiceOutputDtoField[], options: PothosWriterOptions, shouldExport?: boolean, + suffix?: TsCodeFragment, ): PothosTypeDefinitionWithVariableName { const pothosFields = writePothosInputFieldsFromDtoFields(fields, { ...options, @@ -66,19 +67,11 @@ export function writePothosInputDefinitionFromDtoFields( const variableName = `${lowerCaseFirst(name)}InputType`; - const fragment = TsCodeUtils.formatFragment( - `${ - shouldExport ? `export ` : '' - }const VARIABLE_NAME = BUILDER.inputType(NAME, { - fields: (t) => FIELDS - })`, - { - VARIABLE_NAME: variableName, - BUILDER: options.schemaBuilder, - NAME: quot(name), - FIELDS: pothosFields, - }, - ); + const fragment = tsTemplate`${ + shouldExport ? `export ` : '' + }const ${variableName} = ${options.schemaBuilder}.inputType(${quot(name)}, { + fields: (t) => ${pothosFields} + })${suffix ?? ''}`; return { name, @@ -109,6 +102,10 @@ export function getPothosTypeForNestedInput( name, fields, options, + false, + field.zodSchemaFragment + ? tsTemplate`.validate(${field.zodSchemaFragment})` + : undefined, ); return getPothosTypeAsFragment( diff --git a/packages/fastify-generators/src/writers/pothos/resolvers.ts b/packages/fastify-generators/src/writers/pothos/resolvers.ts index 1ccaea6e6..f3e311b8e 100644 --- a/packages/fastify-generators/src/writers/pothos/resolvers.ts +++ b/packages/fastify-generators/src/writers/pothos/resolvers.ts @@ -15,9 +15,17 @@ import type { ServiceOutputDtoNestedField, } from '#src/types/service-output.js'; +interface NestedArgContext { + /** + * If the field is validated, we can skip the restrictObjectNulls call. + */ + isValidatedField?: boolean; +} + function buildNestedArgExpression( arg: ServiceOutputDtoNestedField, tsUtils: TsUtilsImportsProvider, + context: NestedArgContext, ): TsCodeFragment { if (arg.isPrismaType) { throw new Error(`Prisma types are not supported in input fields`); @@ -42,6 +50,7 @@ function buildNestedArgExpression( : `${arg.name}.${nestedField.name}`, }, tsUtils, + context, ), })) .filter((f) => f.expression.contents.includes('restrictObjectNulls')); @@ -78,6 +87,7 @@ function buildNestedArgExpression( function convertNestedArgForCall( arg: ServiceOutputDtoNestedField, tsUtils: TsUtilsImportsProvider, + context: NestedArgContext, ): TsCodeFragment { if (arg.isPrismaType) { throw new Error(`Prisma types are not supported in input fields`); @@ -87,12 +97,17 @@ function convertNestedArgForCall( (f) => f.isOptional && !f.isNullable, ); + const isValidatedField = !!arg.zodSchemaFragment || context.isValidatedField; + const nestedArgExpression: TsCodeFragment = buildNestedArgExpression( arg, tsUtils, + { + isValidatedField, + }, ); - if (nonNullableOptionalFields.length > 0) { + if (nonNullableOptionalFields.length > 0 && !isValidatedField) { return TsCodeUtils.templateWithImports([ tsUtils.restrictObjectNulls.declaration(), ])`restrictObjectNulls(${nestedArgExpression}, [${nonNullableOptionalFields @@ -111,7 +126,7 @@ export function writeValueFromPothosArg( throw new Error(`Optional non-nullable top-level args not handled`); } if (arg.type === 'nested') { - return convertNestedArgForCall(arg, tsUtils); + return convertNestedArgForCall(arg, tsUtils, { isValidatedField: false }); } return tsCodeFragment(arg.name); } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/file-field.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/file-field.ts index 957ac5380..35839acdc 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/file-field.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/file-field.ts @@ -7,13 +7,16 @@ import type { Prisma } from '%prismaGeneratedImports'; import { STORAGE_ADAPTERS } from '$configAdapters'; import { BadRequestError } from '%errorHandlerServiceImports'; import { prisma } from '%prismaImports'; +import { z } from 'zod'; + +const fileInputSchema = z.object({ + id: z.string().uuid(), +}); /** * File input type - accepts a file ID string */ -export interface FileInput { - id: string; -} +export type FileInput = z.infer; /** * Configuration for file field handler @@ -79,7 +82,9 @@ export function fileField< >( config: FileFieldConfig, ): FieldDefinition< - TOptional extends true ? FileInput | null | undefined : FileInput, + TOptional extends true + ? z.ZodOptional> + : typeof fileInputSchema, TOptional extends true ? { connect: { id: string } } | undefined : { connect: { id: string } }, @@ -88,7 +93,15 @@ export function fileField< : { connect: { id: string } } | undefined > { return { - processInput: async (value: FileInput | null | undefined, processCtx) => { + schema: (config.optional + ? fileInputSchema.nullish() + : fileInputSchema) as TOptional extends true + ? z.ZodOptional> + : typeof fileInputSchema, + processInput: async ( + value: z.infer | null | undefined, + processCtx, + ) => { const { serviceContext } = processCtx; // Handle null - disconnect the file diff --git a/tests/simple/apps/backend/baseplate/generated/package.json b/tests/simple/apps/backend/baseplate/generated/package.json index 9a33dc20b..a19397a63 100644 --- a/tests/simple/apps/backend/baseplate/generated/package.json +++ b/tests/simple/apps/backend/baseplate/generated/package.json @@ -39,6 +39,7 @@ "@pothos/plugin-relay": "4.6.2", "@pothos/plugin-simple-objects": "4.1.3", "@pothos/plugin-tracing": "1.1.0", + "@pothos/plugin-validation": "4.2.0", "@pothos/tracing-sentry": "1.1.1", "@prisma/adapter-pg": "6.17.1", "@prisma/client": "6.17.1", diff --git a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts index 6020c5448..84514f678 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts @@ -11,6 +11,7 @@ import type { PothosFieldWithInputPayloadPlugin } from './index.js'; import type { MutationWithInputPayloadOptions, OutputShapeFromFields, + PayloadFieldRef, } from './types.js'; declare global { @@ -56,10 +57,7 @@ declare global { payload: RootFieldBuilder; fieldWithInputPayload: < InputFields extends InputFieldMap, - PayloadFields extends Record< - string, - FieldRef - >, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, Args extends InputFieldMap = Record, @@ -79,7 +77,7 @@ declare global { ShapeFromTypeParam< Types, ObjectRef>, - false + Types['DefaultFieldNullability'] > >; } diff --git a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts index 1a355a40b..12ae4225e 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts @@ -8,6 +8,8 @@ import { import { capitalizeString } from '@src/utils/string.js'; +import type { PayloadFieldRef } from './types.js'; + const rootBuilderProto = RootFieldBuilder.prototype as PothosSchemaTypes.RootFieldBuilder< SchemaTypes, @@ -27,11 +29,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ // expose all fields of payload by default const payloadFields = (): Record< string, - FieldRef + PayloadFieldRef > => { for (const key of Object.keys(payload)) { payload[key].onFirstUse((cfg) => { - if (cfg.kind === 'Object') { + if (cfg.kind === 'Object' && !cfg.resolve) { cfg.resolve = (parent) => (parent as Record)[key] as Readonly; } @@ -56,9 +58,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ type: payloadRef, nullable: false, ...fieldOptions, - } as never); + } as never) as FieldRef; - fieldRef.onFirstUse((config) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fieldRef.onFirstUse((config: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const capitalizedName = capitalizeString(config.name); const inputName = `${capitalizedName}Input`; const payloadName = `${capitalizedName}Payload`; @@ -66,6 +70,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ if (inputRef) { inputRef.name = inputName; this.builder.inputType(inputRef, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Input type for ${config.name} mutation`, fields: () => input, }); @@ -74,6 +79,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ payloadRef.name = payloadName; this.builder.objectType(payloadRef, { name: payloadName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Payload type for ${config.name} mutation`, fields: payloadFields, }); diff --git a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts index bf2e7d518..90b3586cd 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts @@ -12,6 +12,11 @@ import type { SchemaTypes, } from '@pothos/core'; +export type PayloadFieldRef = Omit< + FieldRef, + 'validate' +>; + export type OutputShapeFromFields = NullableToOptional<{ [K in keyof Fields]: Fields[K] extends GenericFieldRef ? T : never; @@ -23,7 +28,7 @@ export type MutationWithInputPayloadOptions< Kind extends FieldKind, Args extends InputFieldMap, InputFields extends InputFieldMap, - PayloadFields extends Record>, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, > = Omit< diff --git a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts index 0e5f0ce72..a8b58095d 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/plugins/graphql/builder.ts @@ -3,6 +3,7 @@ import PrismaPlugin from '@pothos/plugin-prisma'; import RelayPlugin from '@pothos/plugin-relay'; import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'; import TracingPlugin, { isRootField } from '@pothos/plugin-tracing'; +import ValidationPlugin from '@pothos/plugin-validation'; import { createSentryWrapper } from '@pothos/tracing-sentry'; import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; @@ -44,6 +45,7 @@ export const builder = new SchemaBuilder<{ pothosStripQueryMutationPlugin, RelayPlugin, SimpleObjectsPlugin, + ValidationPlugin, ], prisma: { client: prisma, diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts index b571281de..9c584252c 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/define-operations.ts @@ -1,5 +1,7 @@ import type { Result } from '@prisma/client/runtime/client'; +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import { prisma } from '@src/services/prisma.js'; @@ -20,6 +22,7 @@ import type { InferFieldsOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, OperationHooks, PrismaTransaction, @@ -205,6 +208,49 @@ export async function transformFields< return { data: transformedData, hooks }; } +/** + * ========================================= + * Schema Generation Utilities + * ========================================= + */ + +/** + * Generates a Zod schema for create operations from field definitions. + * + * Extracts the Zod schema from each field definition and combines them + * into a single object schema. This schema can be used for validation + * in GraphQL resolvers, REST endpoints, tRPC procedures, or OpenAPI documentation. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to extract schemas from + * @returns Zod object schema with all fields required + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }; + * + * const schema = generateCreateSchema(fields); + * // schema is z.object({ name: z.string(), email: z.string().email() }) + * + * // Use for validation + * const validated = schema.parse({ name: 'John', email: 'john@example.com' }); + * ``` + */ +export function generateCreateSchema< + TFields extends Record, +>(fields: TFields): InferInputSchema { + const shape = Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.schema]), + ) as { + [K in keyof TFields]: TFields[K]['schema']; + }; + + return z.object(shape) as InferInputSchema; +} + /** * ========================================= * Create Operation @@ -273,6 +319,9 @@ export interface CreateOperationConfig< > >; + /** + * Optional hooks for the operation + */ hooks?: OperationHooks>; } @@ -294,8 +343,22 @@ export interface CreateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type CreateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: CreateOperationInput, +) => Promise>) & { + $dataSchema: InferInputSchema; +}; + /** * Defines a type-safe create operation for a Prisma model. * @@ -349,14 +412,17 @@ export function defineCreateOperation< >, >( config: CreateOperationConfig, -): >( - input: CreateOperationInput, -) => Promise> { - return async >({ +): CreateOperationFunction { + const dataSchema = generateCreateSchema(config.fields); + + const createOperation = async >({ data, query, context, - }: CreateOperationInput) => { + skipValidation, + }: CreateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -364,6 +430,9 @@ export function defineCreateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation ? data : dataSchema.parse(data); + const baseOperationContext: OperationContext< GetPayload, { hasResult: false } @@ -376,20 +445,20 @@ export function defineCreateOperation< // Authorization if (config.authorize) { - await config.authorize(data, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } // Step 1: Transform fields (OUTSIDE TRANSACTION) const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(config.fields, data, { + transformFields(config.fields, validatedData, { operation: 'create', serviceContext: context, allowOptionalFields: false, loadExisting: () => Promise.resolve(undefined), }), config.prepareComputedFields - ? config.prepareComputedFields(data, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -451,6 +520,8 @@ export function defineCreateOperation< return result as GetPayload; }); }; + createOperation.$dataSchema = dataSchema; + return createOperation; } /** @@ -549,8 +620,23 @@ export interface UpdateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type UpdateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: UpdateOperationInput, +) => Promise>) & { + $dataSchema: z.ZodObject<{ + [k in keyof TFields]: z.ZodOptional; + }>; +}; /** * Defines a type-safe update operation for a Prisma model. * @@ -605,15 +691,18 @@ export function defineUpdateOperation< >, >( config: UpdateOperationConfig, -): >( - input: UpdateOperationInput, -) => Promise> { - return async >({ +): UpdateOperationFunction { + const dataSchema = generateCreateSchema(config.fields).partial(); + + const updateOperation = async >({ where, data: inputData, query, context, - }: UpdateOperationInput) => { + skipValidation, + }: UpdateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -621,6 +710,11 @@ export function defineUpdateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation + ? inputData + : dataSchema.parse(inputData); + let existingItem: GetPayload | undefined; const delegate = makeGenericPrismaDelegate(prisma, config.model); @@ -644,27 +738,31 @@ export function defineUpdateOperation< }; // Authorization if (config.authorize) { - await config.authorize(inputData, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } - // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Step 1: Transform fields (outside transaction) // Only transform fields provided in input const fieldsToTransform = Object.fromEntries( - Object.entries(config.fields).filter(([key]) => key in inputData), + Object.entries(config.fields).filter(([key]) => key in validatedData), ) as TFields; const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(fieldsToTransform, inputData as InferInput, { - operation: 'update', - serviceContext: context, - allowOptionalFields: true, - loadExisting: baseOperationContext.loadExisting as () => Promise< - Record - >, - }), + transformFields( + fieldsToTransform, + validatedData as InferInput, + { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }, + ), config.prepareComputedFields - ? config.prepareComputedFields(inputData, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -728,6 +826,8 @@ export function defineUpdateOperation< return result as GetPayload; }); }; + updateOperation.$dataSchema = generateCreateSchema(config.fields).partial(); + return updateOperation; } /** diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts index ab87233ae..f12c65e00 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/field-definitions.ts @@ -17,47 +17,75 @@ import type { InferFieldsCreateOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, TransactionalOperationContext, } from './types.js'; -import { invokeHooks, transformFields } from './define-operations.js'; +import { + generateCreateSchema, + invokeHooks, + transformFields, +} from './define-operations.js'; import { makeGenericPrismaDelegate } from './prisma-utils.js'; /** - * Create a simple scalar field with validation only + * Create a simple scalar field with validation and optional transformation * * This helper creates a field definition that validates input using a Zod schema. - * The validated value is passed through unchanged to the transform step. + * Optionally, you can provide a transform function to convert the validated value + * into a different type for Prisma operations. * * For relation fields (e.g., `userId`), use this helper to validate the ID, * then use relation helpers in the transform step to create Prisma connect/disconnect objects. * + * @template TSchema - The Zod schema type for validation + * @template TTransformed - The output type after transformation (defaults to schema output) * @param schema - Zod schema for validation + * @param options - Optional configuration + * @param options.transform - Function to transform the validated value * @returns Field definition * * @example * ```typescript + * // Simple validation * const fields = { * title: scalarField(z.string()), * ownerId: scalarField(z.string()), // Validated as string * }; * - * // In transform, convert IDs to relations: - * transform: (data) => ({ - * title: data.title, - * owner: relation.required(data.ownerId), - * }) + * // With transformation + * const fields = { + * email: scalarField( + * z.string().email(), + * { transform: (email) => email.toLowerCase() } + * ), + * createdAt: scalarField( + * z.string().datetime(), + * { transform: (dateStr) => new Date(dateStr) } + * ), + * }; * ``` */ -export function scalarField( +export function scalarField< + TSchema extends z.ZodSchema, + TTransformed = z.output, +>( schema: TSchema, -): FieldDefinition, z.output, z.output> { + options?: { + transform?: (value: z.output) => TTransformed; + }, +): FieldDefinition { return { + schema, processInput: (value) => { - const validated = schema.parse(value) as z.output; + // Apply transform if provided + const transformed = options?.transform + ? options.transform(value) + : (value as TTransformed); + return { - data: { create: validated, update: validated }, + data: { create: transformed, update: transformed }, }; }, }; @@ -190,15 +218,13 @@ export interface NestedOneToOneFieldConfig< * This helper creates a field definition for managing one-to-one nested relationships. * It handles nested field validation, transformation, and supports both create and update operations. * - * For create operations: - * - Returns nested create data if input is provided - * - Returns undefined if input is not provided + * The nested entity is created/updated via afterExecute hooks, allowing it to reference + * the parent entity after it has been created. * - * For update operations: - * - Returns upsert if input has a unique identifier (via getWhereUnique) - * - Returns create if input doesn't have a unique identifier - * - Deletes the relation if input is null (requires deleteRelation) - * - Returns undefined if input is not provided (no change) + * Behavior: + * - **Provided value**: Upserts the nested entity (creates if new, updates if exists) + * - **null**: Deletes the nested entity (update only) + * - **undefined**: No change to nested entity * * @param config - Configuration object * @returns Field definition @@ -207,18 +233,24 @@ export interface NestedOneToOneFieldConfig< * ```typescript * const fields = { * userProfile: nestedOneToOneField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userProfile', + * relationName: 'user', * fields: { * bio: scalarField(z.string()), * avatar: fileField(avatarFileCategory), * }, + * getWhereUnique: (parent) => ({ userId: parent.id }), * buildData: (data) => ({ - * bio: data.bio, - * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * create: { + * bio: data.create.bio, + * avatar: data.create.avatar ? { connect: { id: data.create.avatar } } : undefined, + * }, + * update: { + * bio: data.update.bio, + * avatar: data.update.avatar ? { connect: { id: data.update.avatar } } : undefined, + * }, * }), - * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, - * deleteRelation: async () => { - * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); - * }, * }), * }; * ``` @@ -236,11 +268,12 @@ export function nestedOneToOneField< TFields >, ): FieldDefinition< - InferInput | null | undefined, + z.ZodOptional>>, undefined, undefined | { delete: true } > { return { + schema: generateCreateSchema(config.fields).nullish(), processInput: async (value, processCtx) => { // Handle null - delete the relation if (value === null) { @@ -542,7 +575,7 @@ export function nestedOneToManyField< TFields >, ): FieldDefinition< - InferInput[] | undefined, + z.ZodOptional>>, undefined, undefined | { deleteMany: Record } > { @@ -560,6 +593,7 @@ export function nestedOneToManyField< }; return { + schema: generateCreateSchema(config.fields).array().optional(), processInput: async (value, processCtx) => { const { serviceContext, loadExisting } = processCtx; diff --git a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/types.ts b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/types.ts index aba7c5e6b..b37885d95 100644 --- a/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/types.ts +++ b/tests/simple/apps/backend/baseplate/generated/src/utils/data-operations/types.ts @@ -1,4 +1,5 @@ import type { ITXClientDenyList } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; import type { PrismaClient } from '@src/generated/prisma/client.js'; @@ -190,17 +191,18 @@ export interface FieldTransformResult { * Field definition for validating and transforming input values. * * A field definition specifies how to process a single input field: - * - Validate the input value + * - Validate the input value using a Zod schema * - Transform it into Prisma-compatible create/update data * - Optionally attach hooks for side effects * - * @template TInput - The expected input type + * @template TInputSchema - The Zod schema type for validation * @template TCreateOutput - Output type for create operations * @template TUpdateOutput - Output type for update operations * * @example * ```typescript - * const nameField: FieldDefinition = { + * const nameField: FieldDefinition = { + * zodSchema: z.string().min(1), * processInput: (value, ctx) => { * const validated = z.string().min(1).parse(value); * return { @@ -213,16 +215,30 @@ export interface FieldTransformResult { * }; * ``` */ -export interface FieldDefinition { +export interface FieldDefinition< + TInputSchema extends z.ZodSchema, + TCreateOutput, + TUpdateOutput, +> { + /** + * The Zod schema for validating this field's input. + * This schema can be extracted and reused for validation in other contexts + * (e.g., GraphQL mutations, REST endpoints, tRPC procedures). + */ + schema: TInputSchema; + /** * Processes and transforms an input value. * - * @param value - The input value to process + * Note: Validation happens at the operation level (defineCreateOperation/defineUpdateOperation), + * not at the field level. This function receives already-validated input. + * + * @param value - The validated input value to process * @param ctx - Context about the operation * @returns Transformed data and optional hooks */ processInput: ( - value: TInput, + value: z.output, ctx: FieldContext, ) => | Promise> @@ -238,14 +254,6 @@ export type AnyFieldDefinition = FieldDefinition; * ========================================= */ -/** Extracts keys from T where the value type does not include undefined */ -type RequiredKeys = { - [P in keyof T]: T[P] extends Exclude ? P : never; -}[keyof T]; - -/** Makes properties with undefined values optional, keeps others required */ -type OptionalForUndefinedKeys = Partial & Pick>; - /** Identity type that expands type aliases for better IDE tooltips */ type Identity = T extends object ? {} & { @@ -253,12 +261,44 @@ type Identity = T extends object } : T; +/** + * Infers the input schema from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's Zod schema type + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type InputSchema = InferInputSchema; + * // { name: z.ZodString; email?: z.ZodString | undefined } + * ``` + */ +export type InferInputSchema< + TFields extends Record, +> = z.ZodObject<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInputSchema, + any, + any + > + ? TInputSchema + : never; +}>; + /** * Infers the input type from a record of field definitions. * * Creates an object type where: * - Each key corresponds to a field name - * - Each value type is the field's input type + * - Each value type is the field's Zod schema type * - Fields accepting undefined become optional properties * * @template TFields - Record of field definitions @@ -275,17 +315,7 @@ type Identity = T extends object * ``` */ export type InferInput> = - Identity< - OptionalForUndefinedKeys<{ - [K in keyof TFields]: TFields[K] extends FieldDefinition< - infer TInput, - any, - any - > - ? TInput - : never; - }> - >; + z.output>; /** * Infers the output type (create and update) from a single field definition. diff --git a/tests/simple/apps/backend/package.json b/tests/simple/apps/backend/package.json index 9a33dc20b..a19397a63 100644 --- a/tests/simple/apps/backend/package.json +++ b/tests/simple/apps/backend/package.json @@ -39,6 +39,7 @@ "@pothos/plugin-relay": "4.6.2", "@pothos/plugin-simple-objects": "4.1.3", "@pothos/plugin-tracing": "1.1.0", + "@pothos/plugin-validation": "4.2.0", "@pothos/tracing-sentry": "1.1.1", "@prisma/adapter-pg": "6.17.1", "@prisma/client": "6.17.1", diff --git a/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts b/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts index 6020c5448..84514f678 100644 --- a/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts +++ b/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/global-types.ts @@ -11,6 +11,7 @@ import type { PothosFieldWithInputPayloadPlugin } from './index.js'; import type { MutationWithInputPayloadOptions, OutputShapeFromFields, + PayloadFieldRef, } from './types.js'; declare global { @@ -56,10 +57,7 @@ declare global { payload: RootFieldBuilder; fieldWithInputPayload: < InputFields extends InputFieldMap, - PayloadFields extends Record< - string, - FieldRef - >, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, Args extends InputFieldMap = Record, @@ -79,7 +77,7 @@ declare global { ShapeFromTypeParam< Types, ObjectRef>, - false + Types['DefaultFieldNullability'] > >; } diff --git a/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts b/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts index 1a355a40b..12ae4225e 100644 --- a/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts +++ b/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/schema-builder.ts @@ -8,6 +8,8 @@ import { import { capitalizeString } from '@src/utils/string.js'; +import type { PayloadFieldRef } from './types.js'; + const rootBuilderProto = RootFieldBuilder.prototype as PothosSchemaTypes.RootFieldBuilder< SchemaTypes, @@ -27,11 +29,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ // expose all fields of payload by default const payloadFields = (): Record< string, - FieldRef + PayloadFieldRef > => { for (const key of Object.keys(payload)) { payload[key].onFirstUse((cfg) => { - if (cfg.kind === 'Object') { + if (cfg.kind === 'Object' && !cfg.resolve) { cfg.resolve = (parent) => (parent as Record)[key] as Readonly; } @@ -56,9 +58,11 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ type: payloadRef, nullable: false, ...fieldOptions, - } as never); + } as never) as FieldRef; - fieldRef.onFirstUse((config) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fieldRef.onFirstUse((config: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const capitalizedName = capitalizeString(config.name); const inputName = `${capitalizedName}Input`; const payloadName = `${capitalizedName}Payload`; @@ -66,6 +70,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ if (inputRef) { inputRef.name = inputName; this.builder.inputType(inputRef, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Input type for ${config.name} mutation`, fields: () => input, }); @@ -74,6 +79,7 @@ rootBuilderProto.fieldWithInputPayload = function fieldWithInputPayload({ payloadRef.name = payloadName; this.builder.objectType(payloadRef, { name: payloadName, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access description: `Payload type for ${config.name} mutation`, fields: payloadFields, }); diff --git a/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts b/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts index bf2e7d518..90b3586cd 100644 --- a/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts +++ b/tests/simple/apps/backend/src/plugins/graphql/FieldWithInputPayloadPlugin/types.ts @@ -12,6 +12,11 @@ import type { SchemaTypes, } from '@pothos/core'; +export type PayloadFieldRef = Omit< + FieldRef, + 'validate' +>; + export type OutputShapeFromFields = NullableToOptional<{ [K in keyof Fields]: Fields[K] extends GenericFieldRef ? T : never; @@ -23,7 +28,7 @@ export type MutationWithInputPayloadOptions< Kind extends FieldKind, Args extends InputFieldMap, InputFields extends InputFieldMap, - PayloadFields extends Record>, + PayloadFields extends Record>, ResolveShape, ResolveReturnShape, > = Omit< diff --git a/tests/simple/apps/backend/src/plugins/graphql/builder.ts b/tests/simple/apps/backend/src/plugins/graphql/builder.ts index 0e5f0ce72..a8b58095d 100644 --- a/tests/simple/apps/backend/src/plugins/graphql/builder.ts +++ b/tests/simple/apps/backend/src/plugins/graphql/builder.ts @@ -3,6 +3,7 @@ import PrismaPlugin from '@pothos/plugin-prisma'; import RelayPlugin from '@pothos/plugin-relay'; import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'; import TracingPlugin, { isRootField } from '@pothos/plugin-tracing'; +import ValidationPlugin from '@pothos/plugin-validation'; import { createSentryWrapper } from '@pothos/tracing-sentry'; import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js'; @@ -44,6 +45,7 @@ export const builder = new SchemaBuilder<{ pothosStripQueryMutationPlugin, RelayPlugin, SimpleObjectsPlugin, + ValidationPlugin, ], prisma: { client: prisma, diff --git a/tests/simple/apps/backend/src/utils/data-operations/define-operations.ts b/tests/simple/apps/backend/src/utils/data-operations/define-operations.ts index b571281de..9c584252c 100644 --- a/tests/simple/apps/backend/src/utils/data-operations/define-operations.ts +++ b/tests/simple/apps/backend/src/utils/data-operations/define-operations.ts @@ -1,5 +1,7 @@ import type { Result } from '@prisma/client/runtime/client'; +import { z } from 'zod'; + import type { Prisma } from '@src/generated/prisma/client.js'; import { prisma } from '@src/services/prisma.js'; @@ -20,6 +22,7 @@ import type { InferFieldsOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, OperationHooks, PrismaTransaction, @@ -205,6 +208,49 @@ export async function transformFields< return { data: transformedData, hooks }; } +/** + * ========================================= + * Schema Generation Utilities + * ========================================= + */ + +/** + * Generates a Zod schema for create operations from field definitions. + * + * Extracts the Zod schema from each field definition and combines them + * into a single object schema. This schema can be used for validation + * in GraphQL resolvers, REST endpoints, tRPC procedures, or OpenAPI documentation. + * + * @template TFields - Record of field definitions + * @param fields - Field definitions to extract schemas from + * @returns Zod object schema with all fields required + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email()), + * }; + * + * const schema = generateCreateSchema(fields); + * // schema is z.object({ name: z.string(), email: z.string().email() }) + * + * // Use for validation + * const validated = schema.parse({ name: 'John', email: 'john@example.com' }); + * ``` + */ +export function generateCreateSchema< + TFields extends Record, +>(fields: TFields): InferInputSchema { + const shape = Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.schema]), + ) as { + [K in keyof TFields]: TFields[K]['schema']; + }; + + return z.object(shape) as InferInputSchema; +} + /** * ========================================= * Create Operation @@ -273,6 +319,9 @@ export interface CreateOperationConfig< > >; + /** + * Optional hooks for the operation + */ hooks?: OperationHooks>; } @@ -294,8 +343,22 @@ export interface CreateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type CreateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: CreateOperationInput, +) => Promise>) & { + $dataSchema: InferInputSchema; +}; + /** * Defines a type-safe create operation for a Prisma model. * @@ -349,14 +412,17 @@ export function defineCreateOperation< >, >( config: CreateOperationConfig, -): >( - input: CreateOperationInput, -) => Promise> { - return async >({ +): CreateOperationFunction { + const dataSchema = generateCreateSchema(config.fields); + + const createOperation = async >({ data, query, context, - }: CreateOperationInput) => { + skipValidation, + }: CreateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -364,6 +430,9 @@ export function defineCreateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation ? data : dataSchema.parse(data); + const baseOperationContext: OperationContext< GetPayload, { hasResult: false } @@ -376,20 +445,20 @@ export function defineCreateOperation< // Authorization if (config.authorize) { - await config.authorize(data, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } // Step 1: Transform fields (OUTSIDE TRANSACTION) const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(config.fields, data, { + transformFields(config.fields, validatedData, { operation: 'create', serviceContext: context, allowOptionalFields: false, loadExisting: () => Promise.resolve(undefined), }), config.prepareComputedFields - ? config.prepareComputedFields(data, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -451,6 +520,8 @@ export function defineCreateOperation< return result as GetPayload; }); }; + createOperation.$dataSchema = dataSchema; + return createOperation; } /** @@ -549,8 +620,23 @@ export interface UpdateOperationInput< query?: TQueryArgs; /** Service context containing user info, request details, etc. */ context: ServiceContext; + /** + * Skip Zod validation if data has already been validated (avoids double validation). + * Set to true when validation happened at a higher layer (e.g., GraphQL input type validation). + */ + skipValidation?: boolean; } +type UpdateOperationFunction< + TModelName extends ModelPropName, + TFields extends Record, +> = (>( + input: UpdateOperationInput, +) => Promise>) & { + $dataSchema: z.ZodObject<{ + [k in keyof TFields]: z.ZodOptional; + }>; +}; /** * Defines a type-safe update operation for a Prisma model. * @@ -605,15 +691,18 @@ export function defineUpdateOperation< >, >( config: UpdateOperationConfig, -): >( - input: UpdateOperationInput, -) => Promise> { - return async >({ +): UpdateOperationFunction { + const dataSchema = generateCreateSchema(config.fields).partial(); + + const updateOperation = async >({ where, data: inputData, query, context, - }: UpdateOperationInput) => { + skipValidation, + }: UpdateOperationInput): Promise< + GetPayload + > => { // Throw error if query select is provided since we will not necessarily have a full result to return if (query?.select) { throw new Error( @@ -621,6 +710,11 @@ export function defineUpdateOperation< ); } + // Validate data unless skipValidation is true (e.g., when GraphQL already validated) + const validatedData = skipValidation + ? inputData + : dataSchema.parse(inputData); + let existingItem: GetPayload | undefined; const delegate = makeGenericPrismaDelegate(prisma, config.model); @@ -644,27 +738,31 @@ export function defineUpdateOperation< }; // Authorization if (config.authorize) { - await config.authorize(inputData, baseOperationContext); + await config.authorize(validatedData, baseOperationContext); } - // Step 1: Transform fields (OUTSIDE TRANSACTION) + // Step 1: Transform fields (outside transaction) // Only transform fields provided in input const fieldsToTransform = Object.fromEntries( - Object.entries(config.fields).filter(([key]) => key in inputData), + Object.entries(config.fields).filter(([key]) => key in validatedData), ) as TFields; const [{ data: fieldsData, hooks: fieldsHooks }, preparedData] = await Promise.all([ - transformFields(fieldsToTransform, inputData as InferInput, { - operation: 'update', - serviceContext: context, - allowOptionalFields: true, - loadExisting: baseOperationContext.loadExisting as () => Promise< - Record - >, - }), + transformFields( + fieldsToTransform, + validatedData as InferInput, + { + operation: 'update', + serviceContext: context, + allowOptionalFields: true, + loadExisting: baseOperationContext.loadExisting as () => Promise< + Record + >, + }, + ), config.prepareComputedFields - ? config.prepareComputedFields(inputData, baseOperationContext) + ? config.prepareComputedFields(validatedData, baseOperationContext) : Promise.resolve(undefined as TPrepareResult), ]); @@ -728,6 +826,8 @@ export function defineUpdateOperation< return result as GetPayload; }); }; + updateOperation.$dataSchema = generateCreateSchema(config.fields).partial(); + return updateOperation; } /** diff --git a/tests/simple/apps/backend/src/utils/data-operations/field-definitions.ts b/tests/simple/apps/backend/src/utils/data-operations/field-definitions.ts index ab87233ae..f12c65e00 100644 --- a/tests/simple/apps/backend/src/utils/data-operations/field-definitions.ts +++ b/tests/simple/apps/backend/src/utils/data-operations/field-definitions.ts @@ -17,47 +17,75 @@ import type { InferFieldsCreateOutput, InferFieldsUpdateOutput, InferInput, + InferInputSchema, OperationContext, TransactionalOperationContext, } from './types.js'; -import { invokeHooks, transformFields } from './define-operations.js'; +import { + generateCreateSchema, + invokeHooks, + transformFields, +} from './define-operations.js'; import { makeGenericPrismaDelegate } from './prisma-utils.js'; /** - * Create a simple scalar field with validation only + * Create a simple scalar field with validation and optional transformation * * This helper creates a field definition that validates input using a Zod schema. - * The validated value is passed through unchanged to the transform step. + * Optionally, you can provide a transform function to convert the validated value + * into a different type for Prisma operations. * * For relation fields (e.g., `userId`), use this helper to validate the ID, * then use relation helpers in the transform step to create Prisma connect/disconnect objects. * + * @template TSchema - The Zod schema type for validation + * @template TTransformed - The output type after transformation (defaults to schema output) * @param schema - Zod schema for validation + * @param options - Optional configuration + * @param options.transform - Function to transform the validated value * @returns Field definition * * @example * ```typescript + * // Simple validation * const fields = { * title: scalarField(z.string()), * ownerId: scalarField(z.string()), // Validated as string * }; * - * // In transform, convert IDs to relations: - * transform: (data) => ({ - * title: data.title, - * owner: relation.required(data.ownerId), - * }) + * // With transformation + * const fields = { + * email: scalarField( + * z.string().email(), + * { transform: (email) => email.toLowerCase() } + * ), + * createdAt: scalarField( + * z.string().datetime(), + * { transform: (dateStr) => new Date(dateStr) } + * ), + * }; * ``` */ -export function scalarField( +export function scalarField< + TSchema extends z.ZodSchema, + TTransformed = z.output, +>( schema: TSchema, -): FieldDefinition, z.output, z.output> { + options?: { + transform?: (value: z.output) => TTransformed; + }, +): FieldDefinition { return { + schema, processInput: (value) => { - const validated = schema.parse(value) as z.output; + // Apply transform if provided + const transformed = options?.transform + ? options.transform(value) + : (value as TTransformed); + return { - data: { create: validated, update: validated }, + data: { create: transformed, update: transformed }, }; }, }; @@ -190,15 +218,13 @@ export interface NestedOneToOneFieldConfig< * This helper creates a field definition for managing one-to-one nested relationships. * It handles nested field validation, transformation, and supports both create and update operations. * - * For create operations: - * - Returns nested create data if input is provided - * - Returns undefined if input is not provided + * The nested entity is created/updated via afterExecute hooks, allowing it to reference + * the parent entity after it has been created. * - * For update operations: - * - Returns upsert if input has a unique identifier (via getWhereUnique) - * - Returns create if input doesn't have a unique identifier - * - Deletes the relation if input is null (requires deleteRelation) - * - Returns undefined if input is not provided (no change) + * Behavior: + * - **Provided value**: Upserts the nested entity (creates if new, updates if exists) + * - **null**: Deletes the nested entity (update only) + * - **undefined**: No change to nested entity * * @param config - Configuration object * @returns Field definition @@ -207,18 +233,24 @@ export interface NestedOneToOneFieldConfig< * ```typescript * const fields = { * userProfile: nestedOneToOneField({ + * parentModel: createParentModelConfig('user', (user) => ({ id: user.id })), + * model: 'userProfile', + * relationName: 'user', * fields: { * bio: scalarField(z.string()), * avatar: fileField(avatarFileCategory), * }, + * getWhereUnique: (parent) => ({ userId: parent.id }), * buildData: (data) => ({ - * bio: data.bio, - * avatar: data.avatar ? { connect: { id: data.avatar } } : undefined, + * create: { + * bio: data.create.bio, + * avatar: data.create.avatar ? { connect: { id: data.create.avatar } } : undefined, + * }, + * update: { + * bio: data.update.bio, + * avatar: data.update.avatar ? { connect: { id: data.update.avatar } } : undefined, + * }, * }), - * getWhereUnique: (input) => input.id ? { id: input.id } : undefined, - * deleteRelation: async () => { - * await prisma.userProfile.deleteMany({ where: { userId: parentId } }); - * }, * }), * }; * ``` @@ -236,11 +268,12 @@ export function nestedOneToOneField< TFields >, ): FieldDefinition< - InferInput | null | undefined, + z.ZodOptional>>, undefined, undefined | { delete: true } > { return { + schema: generateCreateSchema(config.fields).nullish(), processInput: async (value, processCtx) => { // Handle null - delete the relation if (value === null) { @@ -542,7 +575,7 @@ export function nestedOneToManyField< TFields >, ): FieldDefinition< - InferInput[] | undefined, + z.ZodOptional>>, undefined, undefined | { deleteMany: Record } > { @@ -560,6 +593,7 @@ export function nestedOneToManyField< }; return { + schema: generateCreateSchema(config.fields).array().optional(), processInput: async (value, processCtx) => { const { serviceContext, loadExisting } = processCtx; diff --git a/tests/simple/apps/backend/src/utils/data-operations/types.ts b/tests/simple/apps/backend/src/utils/data-operations/types.ts index aba7c5e6b..b37885d95 100644 --- a/tests/simple/apps/backend/src/utils/data-operations/types.ts +++ b/tests/simple/apps/backend/src/utils/data-operations/types.ts @@ -1,4 +1,5 @@ import type { ITXClientDenyList } from '@prisma/client/runtime/client'; +import type { z } from 'zod'; import type { PrismaClient } from '@src/generated/prisma/client.js'; @@ -190,17 +191,18 @@ export interface FieldTransformResult { * Field definition for validating and transforming input values. * * A field definition specifies how to process a single input field: - * - Validate the input value + * - Validate the input value using a Zod schema * - Transform it into Prisma-compatible create/update data * - Optionally attach hooks for side effects * - * @template TInput - The expected input type + * @template TInputSchema - The Zod schema type for validation * @template TCreateOutput - Output type for create operations * @template TUpdateOutput - Output type for update operations * * @example * ```typescript - * const nameField: FieldDefinition = { + * const nameField: FieldDefinition = { + * zodSchema: z.string().min(1), * processInput: (value, ctx) => { * const validated = z.string().min(1).parse(value); * return { @@ -213,16 +215,30 @@ export interface FieldTransformResult { * }; * ``` */ -export interface FieldDefinition { +export interface FieldDefinition< + TInputSchema extends z.ZodSchema, + TCreateOutput, + TUpdateOutput, +> { + /** + * The Zod schema for validating this field's input. + * This schema can be extracted and reused for validation in other contexts + * (e.g., GraphQL mutations, REST endpoints, tRPC procedures). + */ + schema: TInputSchema; + /** * Processes and transforms an input value. * - * @param value - The input value to process + * Note: Validation happens at the operation level (defineCreateOperation/defineUpdateOperation), + * not at the field level. This function receives already-validated input. + * + * @param value - The validated input value to process * @param ctx - Context about the operation * @returns Transformed data and optional hooks */ processInput: ( - value: TInput, + value: z.output, ctx: FieldContext, ) => | Promise> @@ -238,14 +254,6 @@ export type AnyFieldDefinition = FieldDefinition; * ========================================= */ -/** Extracts keys from T where the value type does not include undefined */ -type RequiredKeys = { - [P in keyof T]: T[P] extends Exclude ? P : never; -}[keyof T]; - -/** Makes properties with undefined values optional, keeps others required */ -type OptionalForUndefinedKeys = Partial & Pick>; - /** Identity type that expands type aliases for better IDE tooltips */ type Identity = T extends object ? {} & { @@ -253,12 +261,44 @@ type Identity = T extends object } : T; +/** + * Infers the input schema from a record of field definitions. + * + * Creates an object type where: + * - Each key corresponds to a field name + * - Each value type is the field's Zod schema type + * + * @template TFields - Record of field definitions + * + * @example + * ```typescript + * const fields = { + * name: scalarField(z.string()), + * email: scalarField(z.string().email().optional()), + * }; + * + * type InputSchema = InferInputSchema; + * // { name: z.ZodString; email?: z.ZodString | undefined } + * ``` + */ +export type InferInputSchema< + TFields extends Record, +> = z.ZodObject<{ + [K in keyof TFields]: TFields[K] extends FieldDefinition< + infer TInputSchema, + any, + any + > + ? TInputSchema + : never; +}>; + /** * Infers the input type from a record of field definitions. * * Creates an object type where: * - Each key corresponds to a field name - * - Each value type is the field's input type + * - Each value type is the field's Zod schema type * - Fields accepting undefined become optional properties * * @template TFields - Record of field definitions @@ -275,17 +315,7 @@ type Identity = T extends object * ``` */ export type InferInput> = - Identity< - OptionalForUndefinedKeys<{ - [K in keyof TFields]: TFields[K] extends FieldDefinition< - infer TInput, - any, - any - > - ? TInput - : never; - }> - >; + z.output>; /** * Infers the output type (create and update) from a single field definition. diff --git a/tests/simple/pnpm-lock.yaml b/tests/simple/pnpm-lock.yaml index 4141c783d..8f33abbba 100644 --- a/tests/simple/pnpm-lock.yaml +++ b/tests/simple/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@pothos/plugin-tracing': specifier: 1.1.0 version: 1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) + '@pothos/plugin-validation': + specifier: 4.2.0 + version: 4.2.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0) '@pothos/tracing-sentry': specifier: 1.1.1 version: 1.1.1(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(@sentry/node@9.17.0)(graphql@16.11.0) @@ -1610,6 +1613,12 @@ packages: '@pothos/core': '*' graphql: '>=16.6.0' + '@pothos/plugin-validation@4.2.0': + resolution: {integrity: sha512-iqqlNzHGhTbWIf6dfouMjJfh72gOQEft7gp0Bl0vhW8WNWuOwVmrXrXtF+Lu3hyZuBY7WfQ44jQ41hXjYmqx0g==} + peerDependencies: + '@pothos/core': '*' + graphql: ^16.10.0 + '@pothos/tracing-sentry@1.1.1': resolution: {integrity: sha512-r9loI0Fc8Qax1dNxM/IuWj+66ApyyE7WamOIdXNC3uKNqIgx71diXOY/74TBSctRabra+z+0bXsQyI3voWcftw==} peerDependencies: @@ -7728,6 +7737,11 @@ snapshots: '@pothos/core': 4.10.0(graphql@16.11.0) graphql: 16.11.0 + '@pothos/plugin-validation@4.2.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)': + dependencies: + '@pothos/core': 4.10.0(graphql@16.11.0) + graphql: 16.11.0 + '@pothos/tracing-sentry@1.1.1(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.0(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(@sentry/node@9.17.0)(graphql@16.11.0)': dependencies: '@pothos/core': 4.10.0(graphql@16.11.0)