Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 11 additions & 4 deletions packages/zod/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
GetTypeDefs,
ModelFieldIsOptional,
SchemaDef,
TypeDefFieldIsArray,
TypeDefFieldIsOptional,
} from '@zenstackhq/schema';
import type Decimal from 'decimal.js';
Expand All @@ -24,7 +25,7 @@ export type GetModelFieldsShape<Schema extends SchemaDef, Model extends GetModel
[Field in GetModelFields<Schema, Model> as FieldIsRelation<Schema, Model, Field> extends true
? never
: Field]: ZodOptionalAndNullableIf<
MapModelFieldToZod<Schema, Model, Field>,
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
ModelFieldIsOptional<Schema, Model, Field>
>;
} & {
Expand Down Expand Up @@ -58,7 +59,10 @@ export type GetModelCreateFieldsShape<Schema extends SchemaDef, Model extends Ge
: FieldIsDelegateDiscriminator<Schema, Model, Field> extends true
? never
: Field]: ZodOptionalIf<
ZodOptionalAndNullableIf<MapModelFieldToZod<Schema, Model, Field>, ModelFieldIsOptional<Schema, Model, Field>>,
ZodOptionalAndNullableIf<
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
ModelFieldIsOptional<Schema, Model, Field>
>,
FieldHasDefault<Schema, Model, Field>
>;
};
Expand All @@ -71,13 +75,16 @@ export type GetModelUpdateFieldsShape<Schema extends SchemaDef, Model extends Ge
: FieldIsDelegateDiscriminator<Schema, Model, Field> extends true
? never
: Field]: z.ZodOptional<
ZodOptionalAndNullableIf<MapModelFieldToZod<Schema, Model, Field>, ModelFieldIsOptional<Schema, Model, Field>>
ZodOptionalAndNullableIf<
ZodArrayIf<MapModelFieldToZod<Schema, Model, Field>, FieldIsArray<Schema, Model, Field>>,
ModelFieldIsOptional<Schema, Model, Field>
>
>;
};

export type GetTypeDefFieldsShape<Schema extends SchemaDef, Type extends GetTypeDefs<Schema>> = {
[Field in GetTypeDefFields<Schema, Type>]: ZodOptionalAndNullableIf<
MapTypeDefFieldToZod<Schema, Type, Field>,
ZodArrayIf<MapTypeDefFieldToZod<Schema, Type, Field>, TypeDefFieldIsArray<Schema, Type, Field>>,
TypeDefFieldIsOptional<Schema, Type, Field>
>;
};
Expand Down
90 changes: 69 additions & 21 deletions packages/zod/test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const validPost = {
title: 'My First Post',
published: true,
authorId: null,
tags: ['announcement', 'update'],
};

describe('SchemaFactory - makeModelSchema', () => {
Expand Down Expand Up @@ -103,6 +104,19 @@ describe('SchemaFactory - makeModelSchema', () => {
// optional scalar (foreign key)
expectTypeOf<Post['authorId']>().toEqualTypeOf<string | null | undefined>();

// scalar array
expectTypeOf<Post['tags']>().toEqualTypeOf<string[]>();

const createPostSchema = factory.makeModelCreateSchema('Post');
type PostCreate = z.infer<typeof createPostSchema>;

expectTypeOf<PostCreate['tags']>().toEqualTypeOf<string[]>();

const updatePostSchema = factory.makeModelUpdateSchema('Post');
type PostUpdate = z.infer<typeof updatePostSchema>;

expectTypeOf<PostUpdate['tags']>().toEqualTypeOf<string[] | undefined>();

// optional relation field present in type
expectTypeOf<Post>().toHaveProperty('author');
const _userSchema = factory.makeModelSchema('User');
Expand Down Expand Up @@ -362,7 +376,7 @@ describe('SchemaFactory - makeModelSchema', () => {
const userSchema = factory.makeModelSchema('User');
const result = userSchema.safeParse({
...validUser,
address: { street: '123 Main St', city: 'Springfield', zip: null },
address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null },
});
expect(result.success).toBe(true);
});
Expand All @@ -371,7 +385,7 @@ describe('SchemaFactory - makeModelSchema', () => {
const userSchema = factory.makeModelSchema('User');
const result = userSchema.safeParse({
...validUser,
address: { street: '123 Main St', city: 'Springfield', zip: '12345' },
address: { residents: [], street: '123 Main St', city: 'Springfield', zip: '12345' },
});
expect(result.success).toBe(true);
});
Expand All @@ -380,7 +394,7 @@ describe('SchemaFactory - makeModelSchema', () => {
const userSchema = factory.makeModelSchema('User');
const result = userSchema.safeParse({
...validUser,
address: { street: '123 Main St', city: 'Springfield', zip: null, extra: 'field' },
address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null, extra: 'field' },
});
expect(result.success).toBe(false);
});
Expand All @@ -389,7 +403,7 @@ describe('SchemaFactory - makeModelSchema', () => {
const userSchema = factory.makeModelSchema('User');
const result = userSchema.safeParse({
...validUser,
address: { street: '123 Main St' },
address: { residents: [], street: '123 Main St' },
});
expect(result.success).toBe(false);
});
Expand Down Expand Up @@ -440,18 +454,21 @@ describe('SchemaFactory - makeModelSchema', () => {
describe('SchemaFactory - makeTypeSchema', () => {
it('generates schema for Address typedef', () => {
const addressSchema = factory.makeTypeSchema('Address');
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: null }).success).toBe(true);
expect(
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success,
).toBe(true);
});

it('rejects Address with missing required field', () => {
const addressSchema = factory.makeTypeSchema('Address');
const result = addressSchema.safeParse({ street: '123 Main' });
const result = addressSchema.safeParse({ residents: [], street: '123 Main' });
expect(result.success).toBe(false);
});

it('rejects Address with extra fields (strict object)', () => {
const addressSchema = factory.makeTypeSchema('Address');
const result = addressSchema.safeParse({
residents: [],
street: '123 Main',
city: 'Springfield',
zip: null,
Expand All @@ -462,47 +479,71 @@ describe('SchemaFactory - makeTypeSchema', () => {

it('accepts Address with optional zip as null', () => {
const addressSchema = factory.makeTypeSchema('Address');
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: null }).success).toBe(true);
expect(
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success,
).toBe(true);
});

it('accepts Address with optional zip as a string', () => {
const addressSchema = factory.makeTypeSchema('Address');
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '12345' }).success).toBe(true);
expect(
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '12345' }).success,
).toBe(true);
});

describe('extra validations', () => {
it('passes when zip is null', () => {
const addressSchema = factory.makeTypeSchema('Address');
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: null }).success).toBe(true);
expect(
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success,
).toBe(true);
});

it('passes when zip is omitted', () => {
const addressSchema = factory.makeTypeSchema('Address');
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield' }).success).toBe(true);
expect(addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield' }).success).toBe(
true,
);
});

it('passes when zip is exactly 5 characters', () => {
const addressSchema = factory.makeTypeSchema('Address');
expect(addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '90210' }).success).toBe(
true,
);
expect(
addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '90210' })
.success,
).toBe(true);
});

it('fails when zip is fewer than 5 characters', () => {
const addressSchema = factory.makeTypeSchema('Address');
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123' });
const result = addressSchema.safeParse({
residents: [],
street: '123 Main',
city: 'Springfield',
zip: '123',
});
expect(result.success).toBe(false);
});

it('fails when zip is more than 5 characters', () => {
const addressSchema = factory.makeTypeSchema('Address');
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123456' });
const result = addressSchema.safeParse({
residents: [],
street: '123 Main',
city: 'Springfield',
zip: '123456',
});
expect(result.success).toBe(false);
});

it('error message matches the configured message', () => {
const addressSchema = factory.makeTypeSchema('Address');
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123' });
const result = addressSchema.safeParse({
residents: [],
street: '123 Main',
city: 'Springfield',
zip: '123',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((i) => i.message)).toContain('Zip code must be exactly 5 characters');
Expand All @@ -511,7 +552,12 @@ describe('SchemaFactory - makeTypeSchema', () => {

it('error path points to the zip field', () => {
const addressSchema = factory.makeTypeSchema('Address');
const result = addressSchema.safeParse({ street: '123 Main', city: 'Springfield', zip: '123' });
const result = addressSchema.safeParse({
residents: [],
street: '123 Main',
city: 'Springfield',
zip: '123',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((i) => i.path)).toContainEqual(['zip']);
Expand All @@ -520,7 +566,7 @@ describe('SchemaFactory - makeTypeSchema', () => {

it('fails when city is too short', () => {
const addressSchema = factory.makeTypeSchema('Address');
const result = addressSchema.safeParse({ street: '123 Main', city: '', zip: '12345' });
const result = addressSchema.safeParse({ residents: [], street: '123 Main', city: '', zip: '12345' });
expect(result.success).toBe(false);
});

Expand All @@ -541,12 +587,14 @@ describe('SchemaFactory - makeTypeSchema', () => {
avatar: null,
metadata: null,
status: 'ACTIVE',
address: { street: '123 Main', city: 'Springfield', zip: '90210' },
address: { residents: [], street: '123 Main', city: 'Springfield', zip: '90210' },
};
expect(userSchema.safeParse(validUser).success).toBe(true);
expect(
userSchema.safeParse({ ...validUser, address: { street: '123 Main', city: 'Springfield', zip: '123' } })
.success,
userSchema.safeParse({
...validUser,
address: { residents: ['Alice'], street: '123 Main', city: 'Springfield', zip: '123' },
}).success,
).toBe(false);
});
});
Expand Down
12 changes: 11 additions & 1 deletion packages/zod/test/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { type SchemaDef, ExpressionUtils } from "@zenstackhq/schema";
export class SchemaType implements SchemaDef {
provider = {
type: "sqlite"
type: "postgresql"
} as const;
models = {
User: {
Expand Down Expand Up @@ -125,6 +125,11 @@ export class SchemaType implements SchemaDef {
name: "published",
type: "Boolean"
},
tags: {
name: "tags",
type: "String",
array: true
},
author: {
name: "author",
type: "User",
Expand Down Expand Up @@ -302,6 +307,11 @@ export class SchemaType implements SchemaDef {
Address: {
name: "Address",
fields: {
residents: {
name: "residents",
type: "String",
array: true
},
street: {
name: "street",
type: "String",
Expand Down
24 changes: 13 additions & 11 deletions packages/zod/test/schema/schema.zmodel
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
datasource db {
provider = 'sqlite'
provider = 'postgresql'
}

enum Status {
Expand All @@ -11,9 +11,10 @@ enum Status {
}

type Address {
street String @meta("description", "Street address line")
city String @length(2)
zip String?
residents String[]
street String @meta("description", "Street address line")
city String @length(2)
zip String?

@@validate(zip == null || length(zip) == 5, "Zip code must be exactly 5 characters", ["zip"])
@@meta("description", "A mailing address")
Expand Down Expand Up @@ -42,20 +43,21 @@ model User {
}

model Post {
id String @id @default(cuid())
id String @id @default(cuid())
title String
published Boolean
author User? @relation(fields: [authorId], references: [id])
tags String[]
author User? @relation(fields: [authorId], references: [id])
authorId String?
}

// --- Computed fields ---
model Product {
id String @id @default(cuid())
name String
price Float
discount Float @default(0)
finalPrice Float @computed
id String @id @default(cuid())
name String
price Float
discount Float @default(0)
finalPrice Float @computed
}

// --- Delegate models ---
Expand Down
Loading