From fe413f6f899b2c1c0eac7f6141008fbfaa489bab Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 15 Sep 2023 00:22:30 -0700 Subject: [PATCH 1/2] feat: add switch to zod plugin to control whether unchecked input types are generated --- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- packages/schema/src/plugins/zod/generator.ts | 19 +++- .../schema/src/plugins/zod/transformer.ts | 55 +++++++++--- packages/server/src/api/rpc/index.ts | 1 - tests/integration/tests/plugins/zod.test.ts | 88 +++++++++++++++++-- 5 files changed, 140 insertions(+), 25 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2f28cead0..9ba516b23 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,7 @@ --- name: Feature request about: Suggest an idea for this project -title: '' +title: '[Feature Request] ' labels: '' assignees: '' --- diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index 360d5142c..790ee7515 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -45,6 +45,9 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. const outputObjectTypes = prismaClientDmmf.schema.outputObjectTypes.prisma; const models: DMMF.Model[] = prismaClientDmmf.datamodel.models; + // whether Prisma's Unchecked* series of input types should be generated + const generateUnchecked = options.noUncheckedInput !== true; + const project = createProject(); // common schemas @@ -71,7 +74,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. Transformer.provider = dataSourceProvider; addMissingInputObjectTypes(inputObjectTypes, outputObjectTypes, models); const aggregateOperationSupport = resolveAggregateOperationSupport(inputObjectTypes); - await generateObjectSchemas(inputObjectTypes, project, output, model); + await generateObjectSchemas(inputObjectTypes, project, output, model, generateUnchecked); // input schemas const transformer = new Transformer({ @@ -82,7 +85,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. zmodel: model, inputObjectTypes, }); - await transformer.generateInputSchemas(); + await transformer.generateInputSchemas(generateUnchecked); } // create barrel file @@ -146,12 +149,16 @@ async function generateObjectSchemas( inputObjectTypes: DMMF.InputType[], project: Project, output: string, - zmodel: Model + zmodel: Model, + generateUnchecked: boolean ) { const moduleNames: string[] = []; for (let i = 0; i < inputObjectTypes.length; i += 1) { const fields = inputObjectTypes[i]?.fields; const name = inputObjectTypes[i]?.name; + if (!generateUnchecked && name.includes('Unchecked')) { + continue; + } const transformer = new Transformer({ name, fields, project, zmodel, inputObjectTypes }); const moduleName = transformer.generateObjectSchema(); moduleNames.push(moduleName); @@ -236,7 +243,11 @@ async function generateModelSchema(model: DataModel, project: Project, output: s // compile "@@validate" to ".refine" const refinements = makeValidationRefinements(model); if (refinements.length > 0) { - writer.writeLine(`function refine(schema: z.ZodType) { return schema${refinements.join('\n')}; }`); + writer.writeLine( + `function refine(schema: z.ZodType) { return schema${refinements.join( + '\n' + )}; }` + ); } // model schema diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index 9d5bf9e20..d34e98e92 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -377,7 +377,7 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; return wrapped; } - async generateInputSchemas() { + async generateInputSchemas(generateUnchecked: boolean) { const globalExports: string[] = []; for (const modelOperation of this.modelOperations) { @@ -460,10 +460,17 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; if (createOne) { imports.push( - `import { ${modelName}CreateInputObjectSchema } from '../objects/${modelName}CreateInput.schema'`, - `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'` + `import { ${modelName}CreateInputObjectSchema } from '../objects/${modelName}CreateInput.schema'` ); - codeBody += `create: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema]) }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'` + ); + } + const dataSchema = generateUnchecked + ? `z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema])` + : `${modelName}CreateInputObjectSchema`; + codeBody += `create: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema} }),`; operations.push(['create', origModelName]); } @@ -494,20 +501,34 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; if (updateOne) { imports.push( `import { ${modelName}UpdateInputObjectSchema } from '../objects/${modelName}UpdateInput.schema'`, - `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'`, `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'` ); - codeBody += `update: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema]), where: ${modelName}WhereUniqueInputObjectSchema }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'` + ); + } + const dataSchema = generateUnchecked + ? `z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema])` + : `${modelName}UpdateInputObjectSchema`; + codeBody += `update: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema}, where: ${modelName}WhereUniqueInputObjectSchema }),`; operations.push(['update', origModelName]); } if (updateMany) { imports.push( `import { ${modelName}UpdateManyMutationInputObjectSchema } from '../objects/${modelName}UpdateManyMutationInput.schema'`, - `import { ${modelName}UncheckedUpdateManyInputObjectSchema } from '../objects/${modelName}UncheckedUpdateManyInput.schema'`, `import { ${modelName}WhereInputObjectSchema } from '../objects/${modelName}WhereInput.schema'` ); - codeBody += `updateMany: z.object({ data: z.union([${modelName}UpdateManyMutationInputObjectSchema, ${modelName}UncheckedUpdateManyInputObjectSchema]), where: ${modelName}WhereInputObjectSchema.optional() }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedUpdateManyInputObjectSchema } from '../objects/${modelName}UncheckedUpdateManyInput.schema'` + ); + } + const dataSchema = generateUnchecked + ? `z.union([${modelName}UpdateManyMutationInputObjectSchema, ${modelName}UncheckedUpdateManyInputObjectSchema])` + : `${modelName}UpdateManyMutationInputObjectSchema`; + codeBody += `updateMany: z.object({ data: ${dataSchema}, where: ${modelName}WhereInputObjectSchema.optional() }),`; operations.push(['updateMany', origModelName]); } @@ -515,11 +536,21 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; imports.push( `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'`, `import { ${modelName}CreateInputObjectSchema } from '../objects/${modelName}CreateInput.schema'`, - `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'`, - `import { ${modelName}UpdateInputObjectSchema } from '../objects/${modelName}UpdateInput.schema'`, - `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'` + `import { ${modelName}UpdateInputObjectSchema } from '../objects/${modelName}UpdateInput.schema'` ); - codeBody += `upsert: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema, create: z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema]), update: z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema]) }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'`, + `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'` + ); + } + const createSchema = generateUnchecked + ? `z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema])` + : `${modelName}CreateInputObjectSchema`; + const updateSchema = generateUnchecked + ? `z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema])` + : `${modelName}UpdateInputObjectSchema`; + codeBody += `upsert: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema, create: ${createSchema}, update: ${updateSchema} }),`; operations.push(['upsert', origModelName]); } diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index c854c5e69..20dee5e33 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -155,7 +155,6 @@ class RequestHandler extends APIHandlerBase { return { status: resCode, body: response }; } catch (err) { if (isPrismaClientKnownRequestError(err)) { - logError(logger, err.code, err.message); const status = ERROR_STATUS_MAPPING[err.code] ?? 400; const { error } = this.makeError( diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts index 40d0cded7..760ef0222 100644 --- a/tests/integration/tests/plugins/zod.test.ts +++ b/tests/integration/tests/plugins/zod.test.ts @@ -15,7 +15,8 @@ describe('Zod plugin tests', () => { }); it('basic generation', async () => { - const model = ` + const { zodSchemas } = await loadSchema( + ` datasource db { provider = 'postgresql' url = env('DATABASE_URL') @@ -24,16 +25,16 @@ describe('Zod plugin tests', () => { generator js { provider = 'prisma-client-js' } - + plugin zod { provider = "@core/zod" } - + enum Role { USER ADMIN } - + model User { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -54,9 +55,9 @@ describe('Zod plugin tests', () => { published Boolean @default(false) viewCount Int @default(0) } - `; - - const { zodSchemas } = await loadSchema(model, { addPrelude: false, pushDb: false }); + `, + { addPrelude: false, pushDb: false } + ); const schemas = zodSchemas.models; expect(schemas.UserSchema).toBeTruthy(); expect(schemas.UserCreateSchema).toBeTruthy(); @@ -74,12 +75,25 @@ describe('Zod plugin tests', () => { schemas.UserCreateSchema.safeParse({ email: 'abc@zenstack.dev', role: 'ADMIN', password: 'abc123' }).success ).toBeTruthy(); + // create unchecked + // create unchecked + expect( + zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123' }, + }).success + ).toBeTruthy(); + // update expect(schemas.UserUpdateSchema.safeParse({}).success).toBeTruthy(); expect(schemas.UserUpdateSchema.safeParse({ email: 'abc@def.com' }).success).toBeFalsy(); expect(schemas.UserUpdateSchema.safeParse({ email: 'def@zenstack.dev' }).success).toBeTruthy(); expect(schemas.UserUpdateSchema.safeParse({ password: 'password456' }).success).toBeTruthy(); + // update unchecked + expect( + zodSchemas.input.UserInputSchema.update.safeParse({ where: { id: 1 }, data: { id: 2 } }).success + ).toBeTruthy(); + // model schema expect(schemas.UserSchema.safeParse({ email: 'abc@zenstack.dev', role: 'ADMIN' }).success).toBeFalsy(); // without omitted field @@ -413,4 +427,64 @@ describe('Zod plugin tests', () => { await loadSchema(model, { addPrelude: false, pushDb: false }); }); + + it('no unchecked input', async () => { + const { zodSchemas } = await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = "@core/zod" + noUncheckedInput = true + } + + enum Role { + USER + ADMIN + } + + model User { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique @email @endsWith('@zenstack.dev') + password String @omit + role Role @default(USER) + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String @length(5, 10) + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + published Boolean @default(false) + viewCount Int @default(0) + } + `, + { addPrelude: false, pushDb: false } + ); + const schemas = zodSchemas.models; + + // create unchecked + expect( + zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123' }, + }).success + ).toBeFalsy(); + + // update unchecked + expect( + zodSchemas.input.UserInputSchema.update.safeParse({ where: { id: 1 }, data: { id: 2 } }).success + ).toBeFalsy(); + }); }); From 0c1d22c1a4ba1704ba1768aab91135df7031f485 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 15 Sep 2023 21:27:45 -0700 Subject: [PATCH 2/2] fix tests --- packages/schema/src/plugins/zod/generator.ts | 2 +- packages/schema/src/plugins/zod/transformer.ts | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index 790ee7515..9caefd62e 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -160,7 +160,7 @@ async function generateObjectSchemas( continue; } const transformer = new Transformer({ name, fields, project, zmodel, inputObjectTypes }); - const moduleName = transformer.generateObjectSchema(); + const moduleName = transformer.generateObjectSchema(generateUnchecked); moduleNames.push(moduleName); } project.createSourceFile( diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index d34e98e92..275461984 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -76,8 +76,8 @@ export default class Transformer { return `export const ${name}Schema = ${schema}`; } - generateObjectSchema() { - const zodObjectSchemaFields = this.generateObjectSchemaFields(); + generateObjectSchema(generateUnchecked: boolean) { + const zodObjectSchemaFields = this.generateObjectSchemaFields(generateUnchecked); const objectSchema = this.prepareObjectSchema(zodObjectSchemaFields); const filePath = path.join(Transformer.outputPath, `objects/${this.name}.schema.ts`); @@ -86,9 +86,9 @@ export default class Transformer { return `${this.name}.schema`; } - generateObjectSchemaFields() { + generateObjectSchemaFields(generateUnchecked: boolean) { const zodObjectSchemaFields = this.fields - .map((field) => this.generateObjectSchemaField(field)) + .map((field) => this.generateObjectSchemaField(field, generateUnchecked)) .flatMap((item) => item) .map((item) => { const [zodStringWithMainType, field, skipValidators] = item; @@ -102,7 +102,10 @@ export default class Transformer { return zodObjectSchemaFields; } - generateObjectSchemaField(field: PrismaDMMF.SchemaArg): [string, PrismaDMMF.SchemaArg, boolean][] { + generateObjectSchemaField( + field: PrismaDMMF.SchemaArg, + generateUnchecked: boolean + ): [string, PrismaDMMF.SchemaArg, boolean][] { const lines = field.inputTypes; if (lines.length === 0) { @@ -110,6 +113,10 @@ export default class Transformer { } let alternatives = lines.reduce((result, inputType) => { + if (!generateUnchecked && typeof inputType.type === 'string' && inputType.type.includes('Unchecked')) { + return result; + } + if (inputType.type === 'String') { result.push(this.wrapWithZodValidators('z.string()', field, inputType)); } else if (inputType.type === 'Int' || inputType.type === 'Float') {