Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Clean up skipValidation to allow for validation of contents prior to …
…submission
  • Loading branch information
kingston committed Nov 20, 2025
commit fb435ccaab6f6c0460709257bac0e952ea649860
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ builder.mutationField('createUser', (t) =>
data,
context,
query: queryFromInfo({ context, info, path: ['user'] }),
skipValidation: true,
});
return { user };
},
Expand Down Expand Up @@ -61,6 +62,7 @@ builder.mutationField('updateUser', (t) =>
data,
context,
query: queryFromInfo({ context, info, path: ['user'] }),
skipValidation: true,
});
return { user };
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export function generateCreateSchema<
[K in keyof TFields]: TFields[K]['schema'];
};

return z.object(shape);
return z.object(shape) as InferInputSchema<TFields>;
}

/**
Expand Down Expand Up @@ -319,6 +319,9 @@ export interface CreateOperationConfig<
>
>;

/**
* Optional hooks for the operation
*/
hooks?: OperationHooks<GetPayload<TModelName>>;
}

Expand All @@ -340,6 +343,11 @@ 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<
Expand Down Expand Up @@ -405,10 +413,13 @@ export function defineCreateOperation<
>(
config: CreateOperationConfig<TModelName, TFields, TPrepareResult>,
): CreateOperationFunction<TModelName, TFields> {
const dataSchema = generateCreateSchema(config.fields);

const createOperation = async <TQueryArgs extends ModelQuery<TModelName>>({
data,
query,
context,
skipValidation,
}: CreateOperationInput<TModelName, TFields, TQueryArgs>): Promise<
GetPayload<TModelName, TQueryArgs>
> => {
Expand All @@ -419,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<TModelName>,
{ hasResult: false }
Expand All @@ -431,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),
]);

Expand Down Expand Up @@ -506,7 +520,7 @@ export function defineCreateOperation<
return result as GetPayload<TModelName, TQueryArgs>;
});
};
createOperation.$dataSchema = generateCreateSchema(config.fields);
createOperation.$dataSchema = dataSchema;
return createOperation;
}

Expand Down Expand Up @@ -606,6 +620,11 @@ 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<
Expand Down Expand Up @@ -673,11 +692,14 @@ export function defineUpdateOperation<
>(
config: UpdateOperationConfig<TModelName, TFields, TPrepareResult>,
): UpdateOperationFunction<TModelName, TFields> {
const dataSchema = generateCreateSchema(config.fields).partial();

const updateOperation = async <TQueryArgs extends ModelQuery<TModelName>>({
where,
data: inputData,
query,
context,
skipValidation,
}: UpdateOperationInput<TModelName, TFields, TQueryArgs>): Promise<
GetPayload<TModelName, TQueryArgs>
> => {
Expand All @@ -688,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<TModelName> | undefined;

const delegate = makeGenericPrismaDelegate(prisma, config.model);
Expand All @@ -711,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<TFields>, {
operation: 'update',
serviceContext: context,
allowOptionalFields: true,
loadExisting: baseOperationContext.loadExisting as () => Promise<
Record<string, unknown>
>,
}),
transformFields(
fieldsToTransform,
validatedData as InferInput<TFields>,
{
operation: 'update',
serviceContext: context,
allowOptionalFields: true,
loadExisting: baseOperationContext.loadExisting as () => Promise<
Record<string, unknown>
>,
},
),
config.prepareComputedFields
? config.prepareComputedFields(inputData, baseOperationContext)
? config.prepareComputedFields(validatedData, baseOperationContext)
: Promise.resolve(undefined as TPrepareResult),
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,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
Expand All @@ -235,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 } });
* },
* }),
* };
* ```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,6 @@ export interface FieldContext {
serviceContext: ServiceContext;
/** Function to load existing model data (for updates) */
loadExisting: () => Promise<object | undefined>;
/** Skip Zod validation if data has already been validated (avoids double validation) */
skipValidation?: boolean;
}

/**
Expand Down Expand Up @@ -232,7 +230,10 @@ export interface FieldDefinition<
/**
* 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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ builder.mutationField('createUser', (t) =>
data,
context,
query: queryFromInfo({ context, info, path: ['user'] }),
skipValidation: true,
});
return { user };
},
Expand Down Expand Up @@ -61,6 +62,7 @@ builder.mutationField('updateUser', (t) =>
data,
context,
query: queryFromInfo({ context, info, path: ['user'] }),
skipValidation: true,
});
return { user };
},
Expand Down
Loading