Skip to content

Commit e46ddc5

Browse files
authored
fix(zod): exclude computed and delegate fields from create/update schemas (#2418)
1 parent 7c83f7c commit e46ddc5

6 files changed

Lines changed: 416 additions & 5 deletions

File tree

packages/zod/src/factory.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ class SchemaFactory<Schema extends SchemaDef> {
7171
const fields: Record<string, z.ZodType> = {};
7272

7373
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
74-
if (fieldDef.relation) {
74+
// exclude relation, computed, delegate discriminator fields
75+
if (fieldDef.relation || fieldDef.computed || fieldDef.isDiscriminator) {
7576
continue;
7677
}
7778

@@ -96,7 +97,8 @@ class SchemaFactory<Schema extends SchemaDef> {
9697
const fields: Record<string, z.ZodType> = {};
9798

9899
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
99-
if (fieldDef.relation) {
100+
// exclude relation, computed, delegate discriminator fields
101+
if (fieldDef.relation || fieldDef.computed || fieldDef.isDiscriminator) {
100102
continue;
101103
}
102104

packages/zod/src/types.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type {
22
FieldHasDefault,
33
FieldIsArray,
4+
FieldIsComputed,
5+
FieldIsDelegateDiscriminator,
46
FieldIsRelation,
57
GetEnum,
68
GetEnums,
@@ -51,7 +53,11 @@ export type GetModelFieldsShape<Schema extends SchemaDef, Model extends GetModel
5153
export type GetModelCreateFieldsShape<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
5254
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
5355
? never
54-
: Field]: ZodOptionalIf<
56+
: FieldIsComputed<Schema, Model, Field> extends true
57+
? never
58+
: FieldIsDelegateDiscriminator<Schema, Model, Field> extends true
59+
? never
60+
: Field]: ZodOptionalIf<
5561
ZodOptionalAndNullableIf<MapModelFieldToZod<Schema, Model, Field>, ModelFieldIsOptional<Schema, Model, Field>>,
5662
FieldHasDefault<Schema, Model, Field>
5763
>;
@@ -60,7 +66,11 @@ export type GetModelCreateFieldsShape<Schema extends SchemaDef, Model extends Ge
6066
export type GetModelUpdateFieldsShape<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
6167
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
6268
? never
63-
: Field]: z.ZodOptional<
69+
: FieldIsComputed<Schema, Model, Field> extends true
70+
? never
71+
: FieldIsDelegateDiscriminator<Schema, Model, Field> extends true
72+
? never
73+
: Field]: z.ZodOptional<
6474
ZodOptionalAndNullableIf<MapModelFieldToZod<Schema, Model, Field>, ModelFieldIsOptional<Schema, Model, Field>>
6575
>;
6676
};

packages/zod/test/factory.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,213 @@ describe('SchemaFactory - makeEnumSchema', () => {
636636
expect(() => factory.makeEnumSchema('Unknown' as any)).toThrow();
637637
});
638638
});
639+
640+
// --- Computed fields tests ---
641+
642+
const validProduct = {
643+
id: 'prod1',
644+
name: 'Widget',
645+
price: 10.0,
646+
discount: 2.0,
647+
finalPrice: 8.0,
648+
};
649+
650+
describe('SchemaFactory - computed fields', () => {
651+
describe('makeModelSchema includes computed fields', () => {
652+
it('accepts a Product with computed field present', () => {
653+
const productSchema = factory.makeModelSchema('Product');
654+
expect(productSchema.safeParse(validProduct).success).toBe(true);
655+
});
656+
657+
it('rejects a Product missing the computed field', () => {
658+
const productSchema = factory.makeModelSchema('Product');
659+
const { finalPrice: _, ...withoutComputed } = validProduct;
660+
expect(productSchema.safeParse(withoutComputed).success).toBe(false);
661+
});
662+
663+
it('infers computed field in model schema type', () => {
664+
const _schema = factory.makeModelSchema('Product');
665+
type Product = z.infer<typeof _schema>;
666+
expectTypeOf<Product['finalPrice']>().toEqualTypeOf<number>();
667+
});
668+
});
669+
670+
describe('makeModelCreateSchema excludes computed fields', () => {
671+
it('accepts a Product without the computed field', () => {
672+
const createSchema = factory.makeModelCreateSchema('Product');
673+
expect(createSchema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(true);
674+
});
675+
676+
it('rejects a Product with the computed field (strict)', () => {
677+
const createSchema = factory.makeModelCreateSchema('Product');
678+
expect(createSchema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(false);
679+
});
680+
681+
it('does not include computed field in create schema type', () => {
682+
const _schema = factory.makeModelCreateSchema('Product');
683+
type ProductCreate = z.infer<typeof _schema>;
684+
expectTypeOf<ProductCreate>().not.toHaveProperty('finalPrice');
685+
// own fields are present
686+
expectTypeOf<ProductCreate>().toHaveProperty('name');
687+
expectTypeOf<ProductCreate['name']>().toEqualTypeOf<string>();
688+
expectTypeOf<ProductCreate['price']>().toEqualTypeOf<number>();
689+
// field with default is optional
690+
expectTypeOf<ProductCreate>().toHaveProperty('discount');
691+
});
692+
});
693+
694+
describe('makeModelUpdateSchema excludes computed fields', () => {
695+
it('accepts a Product update without the computed field', () => {
696+
const updateSchema = factory.makeModelUpdateSchema('Product');
697+
expect(updateSchema.safeParse({ price: 12.0 }).success).toBe(true);
698+
});
699+
700+
it('rejects a Product update with the computed field (strict)', () => {
701+
const updateSchema = factory.makeModelUpdateSchema('Product');
702+
expect(updateSchema.safeParse({ price: 12.0, finalPrice: 10.0 }).success).toBe(false);
703+
});
704+
705+
it('does not include computed field in update schema type', () => {
706+
const _schema = factory.makeModelUpdateSchema('Product');
707+
type ProductUpdate = z.infer<typeof _schema>;
708+
expectTypeOf<ProductUpdate>().not.toHaveProperty('finalPrice');
709+
// own fields are present (all optional in update)
710+
expectTypeOf<ProductUpdate>().toHaveProperty('name');
711+
});
712+
});
713+
});
714+
715+
// --- Delegate model tests ---
716+
717+
const validVideo = {
718+
id: 1,
719+
createdAt: new Date(),
720+
assetType: 'Video',
721+
duration: 120,
722+
url: 'https://example.com/video.mp4',
723+
};
724+
725+
const validImage = {
726+
id: 2,
727+
createdAt: new Date(),
728+
assetType: 'Image',
729+
format: 'png',
730+
width: 800,
731+
};
732+
733+
describe('SchemaFactory - delegate models', () => {
734+
describe('makeModelSchema for delegate base model', () => {
735+
it('accepts a valid Asset', () => {
736+
const assetSchema = factory.makeModelSchema('Asset');
737+
expect(assetSchema.safeParse({ id: 1, createdAt: new Date(), assetType: 'Video' }).success).toBe(true);
738+
});
739+
740+
it('includes discriminator field in model schema type', () => {
741+
const _schema = factory.makeModelSchema('Asset');
742+
type Asset = z.infer<typeof _schema>;
743+
expectTypeOf<Asset['assetType']>().toEqualTypeOf<string>();
744+
expectTypeOf<Asset['id']>().toEqualTypeOf<number>();
745+
});
746+
});
747+
748+
describe('makeModelSchema for derived models', () => {
749+
it('accepts a valid Video (includes inherited + own fields)', () => {
750+
const videoSchema = factory.makeModelSchema('Video');
751+
expect(videoSchema.safeParse(validVideo).success).toBe(true);
752+
});
753+
754+
it('accepts a valid Image (includes inherited + own fields)', () => {
755+
const imageSchema = factory.makeModelSchema('Image');
756+
expect(imageSchema.safeParse(validImage).success).toBe(true);
757+
});
758+
759+
it('rejects Video missing own fields', () => {
760+
const videoSchema = factory.makeModelSchema('Video');
761+
const { duration: _, url: _u, ...withoutOwn } = validVideo;
762+
expect(videoSchema.safeParse(withoutOwn).success).toBe(false);
763+
});
764+
765+
it('infers correct types for derived model including inherited fields', () => {
766+
const _schema = factory.makeModelSchema('Video');
767+
type Video = z.infer<typeof _schema>;
768+
// inherited fields
769+
expectTypeOf<Video['id']>().toEqualTypeOf<number>();
770+
expectTypeOf<Video['assetType']>().toEqualTypeOf<string>();
771+
// own fields
772+
expectTypeOf<Video['duration']>().toEqualTypeOf<number>();
773+
expectTypeOf<Video['url']>().toEqualTypeOf<string>();
774+
});
775+
});
776+
777+
describe('makeModelCreateSchema excludes discriminator', () => {
778+
it('accepts Video create without discriminator and inherited fields', () => {
779+
const createSchema = factory.makeModelCreateSchema('Video');
780+
// Only own non-inherited, non-discriminator fields should be required
781+
expect(createSchema.safeParse({ duration: 120, url: 'https://example.com/video.mp4' }).success).toBe(true);
782+
});
783+
784+
it('rejects Video create with discriminator field (strict)', () => {
785+
const createSchema = factory.makeModelCreateSchema('Video');
786+
expect(
787+
createSchema.safeParse({
788+
duration: 120,
789+
url: 'https://example.com/video.mp4',
790+
assetType: 'Video',
791+
}).success,
792+
).toBe(false);
793+
});
794+
795+
it('does not include discriminator fields in create schema type', () => {
796+
const _schema = factory.makeModelCreateSchema('Video');
797+
type VideoCreate = z.infer<typeof _schema>;
798+
// discriminator and originModel fields should be excluded
799+
expectTypeOf<VideoCreate>().not.toHaveProperty('assetType');
800+
// own fields should be present
801+
expectTypeOf<VideoCreate>().toHaveProperty('duration');
802+
expectTypeOf<VideoCreate>().toHaveProperty('url');
803+
expectTypeOf<VideoCreate['duration']>().toEqualTypeOf<number>();
804+
expectTypeOf<VideoCreate['url']>().toEqualTypeOf<string>();
805+
});
806+
807+
it('excludes discriminator from base delegate create schema', () => {
808+
const createSchema = factory.makeModelCreateSchema('Asset');
809+
// discriminator should not be included
810+
expect(createSchema.safeParse({ assetType: 'Video' }).success).toBe(false);
811+
// empty create (id has default, createdAt has default, assetType is discriminator)
812+
expect(createSchema.safeParse({}).success).toBe(true);
813+
});
814+
815+
it('does not include discriminator in base delegate create schema type', () => {
816+
const _schema = factory.makeModelCreateSchema('Asset');
817+
type AssetCreate = z.infer<typeof _schema>;
818+
expectTypeOf<AssetCreate>().not.toHaveProperty('assetType');
819+
});
820+
});
821+
822+
describe('makeModelUpdateSchema excludes discriminator and originModel fields', () => {
823+
it('accepts Video update with only own fields', () => {
824+
const updateSchema = factory.makeModelUpdateSchema('Video');
825+
expect(updateSchema.safeParse({ duration: 180 }).success).toBe(true);
826+
});
827+
828+
it('rejects Video update with discriminator field (strict)', () => {
829+
const updateSchema = factory.makeModelUpdateSchema('Video');
830+
expect(updateSchema.safeParse({ duration: 180, assetType: 'Video' }).success).toBe(false);
831+
});
832+
833+
it('does not include discriminator fields in update schema type', () => {
834+
const _schema = factory.makeModelUpdateSchema('Video');
835+
type VideoUpdate = z.infer<typeof _schema>;
836+
expectTypeOf<VideoUpdate>().not.toHaveProperty('assetType');
837+
// own fields should be present (all optional in update)
838+
expectTypeOf<VideoUpdate>().toHaveProperty('duration');
839+
expectTypeOf<VideoUpdate>().toHaveProperty('url');
840+
});
841+
842+
it('does not include discriminator in base delegate update schema type', () => {
843+
const _schema = factory.makeModelUpdateSchema('Asset');
844+
type AssetUpdate = z.infer<typeof _schema>;
845+
expectTypeOf<AssetUpdate>().not.toHaveProperty('assetType');
846+
});
847+
});
848+
});

0 commit comments

Comments
 (0)