From 319e2078c57542228d7e710c829b7a68a54b6eae Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:42:12 -0700 Subject: [PATCH] fix(orm): enforce field validation attributes and `@@validate` inside custom types The ORM built the input schema of a typed JSON field from the custom type's field types only. Validation attributes on the type's fields (`@length`, `@gte`, `@email`, ...) and the type's own `@@validate` rules were dropped, so create, update and procedure calls accepted values that violate them, while `@zenstackhq/zod`'s `makeTypeSchema` enforces the same rules. `makeTypeDefSchema` now passes each field's attributes to `makeScalarSchema`, applies `@length` to list fields with `addListValidation`, and applies the type's `@@validate` rules with `addCustomValidation`, the same way model fields and primitive custom types are handled. Fixes #2844 Co-Authored-By: Claude Opus 5.5 --- packages/orm/src/client/zod/factory.ts | 19 +++-- tests/regression/test/issue-2844.test.ts | 94 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 tests/regression/test/issue-2844.test.ts diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 53014d4b1..ff045382f 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -483,16 +483,18 @@ export class ZodSchemaFactory< return this.extraValidationsEnabled ? ZodUtils.addCustomValidation(schema, typeDef.attributes) : schema; } const func = typeDef.strict ? z.strictObject : z.looseObject; - const schema = func( + const objectSchema = func( Object.fromEntries( Object.entries(typeDef.fields).map(([field, def]) => { - // Wrap nested typedef references in z.lazy() so cyclic or self-referencing - // typedefs don't recurse infinitely while building schemas. - let fieldSchema: ZodType = isTypeDef(this.schema, def.type) - ? z.lazy(() => this.makeTypeDefSchema(def.type)) - : this.makeScalarSchema(def.type); + // Wrap nested object typedef references in z.lazy() so cyclic or self-referencing + // typedefs don't recurse infinitely while building schemas. All other fields get + // their validation attributes applied, the same as model fields. + let fieldSchema: ZodType = + isTypeDef(this.schema, def.type) && !isPrimitiveTypeDef(this.schema, def.type) + ? z.lazy(() => this.makeTypeDefSchema(def.type)) + : this.makeScalarSchema(def.type, this.getItemAttributes(def)); if (def.array) { - fieldSchema = fieldSchema.array(); + fieldSchema = ZodUtils.addListValidation(fieldSchema.array(), def.attributes); } if (def.optional) { fieldSchema = fieldSchema.nullish(); @@ -501,6 +503,9 @@ export class ZodSchemaFactory< }), ), ); + const schema = this.extraValidationsEnabled + ? ZodUtils.addCustomValidation(objectSchema, typeDef.attributes) + : objectSchema; // zod doesn't preserve object field order after parsing, here we use a // validation-only custom schema and use the original data if parsing diff --git a/tests/regression/test/issue-2844.test.ts b/tests/regression/test/issue-2844.test.ts new file mode 100644 index 000000000..570531123 --- /dev/null +++ b/tests/regression/test/issue-2844.test.ts @@ -0,0 +1,94 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2844 +describe('Regression for issue 2844', () => { + const schema = ` +type Bounds { + caption String @length(1, 3) + low Int @gte(0) + high Int + + @@validate(low <= high, 'low must not exceed high') +} + +model Example { + id Int @id + bounds Bounds @json +} +`; + + it('enforces custom type validation rules on create', async () => { + const db = await createTestClient(schema); + + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'too long', low: 0, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.caption']); + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'ok', low: -1, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.low']); + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'ok', low: 5, high: 1 } } }), + ).toBeRejectedByValidation(['low must not exceed high']); + + await expect( + db.example.create({ data: { id: 1, bounds: { caption: 'abc', low: 0, high: 0 } } }), + ).resolves.toMatchObject({ bounds: { caption: 'abc', low: 0, high: 0 } }); + }); + + it('enforces custom type validation rules on update', async () => { + const db = await createTestClient(schema); + await db.example.create({ data: { id: 1, bounds: { caption: 'ok', low: 0, high: 1 } } }); + + await expect( + db.example.update({ where: { id: 1 }, data: { bounds: { caption: 'too long', low: 0, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.caption']); + await expect( + db.example.update({ where: { id: 1 }, data: { bounds: { caption: 'ok', low: -1, high: 1 } } }), + ).toBeRejectedByValidation(['data.bounds.low']); + await expect( + db.example.update({ where: { id: 1 }, data: { bounds: { caption: 'ok', low: 5, high: 1 } } }), + ).toBeRejectedByValidation(['low must not exceed high']); + + await expect(db.example.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + bounds: { caption: 'ok', low: 0, high: 1 }, + }); + }); + + it('enforces validation rules of nested custom types and list fields', async () => { + const db = await createTestClient(` +type Item { + name String @length(1, 5) +} + +type Cart { + owner String @email + items Item[] @length(1, 2) +} + +model Order { + id Int @id + cart Cart @json +} +`); + + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'not-an-email', items: [{ name: 'a' }] } } }), + ).toBeRejectedByValidation(['data.cart.owner']); + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'a@b.com', items: [{ name: 'too long' }] } } }), + ).toBeRejectedByValidation(['data.cart.items[0].name']); + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'a@b.com', items: [] } } }), + ).toBeRejectedByValidation(['data.cart.items']); + await expect( + db.order.create({ + data: { id: 1, cart: { owner: 'a@b.com', items: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] } }, + }), + ).toBeRejectedByValidation(['data.cart.items']); + + await expect( + db.order.create({ data: { id: 1, cart: { owner: 'a@b.com', items: [{ name: 'apple' }] } } }), + ).resolves.toMatchObject({ cart: { owner: 'a@b.com', items: [{ name: 'apple' }] } }); + }); +});