Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/cli/test/db/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ enum Status {
`model User {
id Int @id @default(autoincrement())
email String @unique @email
phone String @phone
name String @length(min: 2, max: 100)
website String? @url
code String? @regex('^[A-Z]+$')
Expand Down
11 changes: 11 additions & 0 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,11 @@ attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validat
*/
attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation

/**
* Validates a string field value is a valid E.164 phone number.
*/
attribute @phone(_ message: String?) @@@targetField([StringField]) @@@validation

/**
* Trims whitespaces from the start and end of the string.
*/
Expand Down Expand Up @@ -622,6 +627,12 @@ function isDateTime(field: String): Boolean {
function isUrl(field: String): Boolean {
} @@@expressionContext([ValidationRule])

/**
* Validates a string field value is a valid E.164 phone number.
*/
function isPhone(field: String): Boolean {
} @@@expressionContext([ValidationRule])

//////////////////////////////////////////////
// End validation attributes and functions
//////////////////////////////////////////////
Expand Down
12 changes: 11 additions & 1 deletion packages/zod/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ export function addStringValidation(
case '@email':
result = result.email();
break;
case '@phone':
result = result.e164();
break;
case '@datetime':
result = result.datetime();
break;
Expand Down Expand Up @@ -533,12 +536,19 @@ function evalCall(data: any, expr: CallExpression) {
}
case 'isEmail':
case 'isUrl':
case 'isPhone':
case 'isDateTime': {
if (fieldArg === undefined || fieldArg === null || fieldArg === ABSENT) {
return false;
}
invariant(typeof fieldArg === 'string', `"${f}" first argument must be a string`);
const fn = f === 'isEmail' ? ('email' as const) : f === 'isUrl' ? ('url' as const) : ('datetime' as const);
const fn = f === 'isEmail'
? ('email' as const)
: f === 'isUrl'
? ('url' as const)
: f === 'isPhone'
? ('e164' as const)
: ('datetime' as const);
return z.string()[fn]().safeParse(fieldArg).success;
}
// list functions
Expand Down
25 changes: 25 additions & 0 deletions packages/zod/test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const factory = createSchemaFactory(schema);
const validUser = {
id: 'user123',
email: 'test@example.com',
phone: '+15555555555',
username: 'johndoe',
website: null,
code: 'USR001',
Expand Down Expand Up @@ -44,6 +45,7 @@ describe('SchemaFactory - makeModelSchema', () => {
// required string fields
expectTypeOf<User['id']>().toEqualTypeOf<string>();
expectTypeOf<User['email']>().toEqualTypeOf<string>();
expectTypeOf<User['phone']>().toEqualTypeOf<string>();
expectTypeOf<User['username']>().toEqualTypeOf<string>();
expectTypeOf<User['code']>().toEqualTypeOf<string>();
// optional string field (nullable + optional)
Expand Down Expand Up @@ -259,6 +261,18 @@ describe('SchemaFactory - makeModelSchema', () => {
expect(result.success).toBe(true);
});

it('rejects invalid phone number for @phone field', () => {
const userSchema = factory.makeModelSchema('User');
const result = userSchema.safeParse({ ...validUser, phone: 'not-a-phone' });
expect(result.success).toBe(false);
Comment thread
sanny-io marked this conversation as resolved.
});

it('accepts valid phone number for @phone field', () => {
const userSchema = factory.makeModelSchema('User');
const result = userSchema.safeParse({ ...validUser, phone: '+15555555555' });
expect(result.success).toBe(true);
});

it('rejects code that does not start with "USR" for @startsWith', () => {
const userSchema = factory.makeModelSchema('User');
const result = userSchema.safeParse({ ...validUser, code: 'ABC001' });
Expand Down Expand Up @@ -576,6 +590,7 @@ describe('SchemaFactory - makeTypeSchema', () => {
const validUser = {
id: 'u1',
email: 'a@b.com',
phone: '+15555555555',
username: 'alice',
website: null,
code: 'USR01',
Expand Down Expand Up @@ -934,7 +949,9 @@ describe('SchemaFactory - makeModelSchema with options', () => {
expectTypeOf<Result>().toHaveProperty('id');
expectTypeOf<Result['id']>().toEqualTypeOf<string>();
expectTypeOf<Result>().toHaveProperty('email');
expectTypeOf<Result>().toHaveProperty('phone');
expectTypeOf<Result['email']>().toEqualTypeOf<string>();
expectTypeOf<Result['phone']>().toEqualTypeOf<string>();
});

it('omit: {} (empty) keeps all scalar fields', () => {
Expand All @@ -954,6 +971,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
expectTypeOf<Result>().not.toHaveProperty('username');
expectTypeOf<Result>().not.toHaveProperty('avatar');
expectTypeOf<Result>().toHaveProperty('email');
expectTypeOf<Result>().toHaveProperty('phone');
});
});

Expand Down Expand Up @@ -985,6 +1003,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
type Result = z.infer<typeof _schema>;
expectTypeOf<Result['id']>().toEqualTypeOf<string>();
expectTypeOf<Result['email']>().toEqualTypeOf<string>();
expectTypeOf<Result['phone']>().toEqualTypeOf<string>();
expectTypeOf<Result['username']>().toEqualTypeOf<string>();
});

Expand Down Expand Up @@ -1039,6 +1058,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
type Result = z.infer<typeof _schema>;
expectTypeOf<Result>().not.toHaveProperty('username');
expectTypeOf<Result>().toHaveProperty('email');
expectTypeOf<Result>().toHaveProperty('phone');
expectTypeOf<Result>().toHaveProperty('posts');
});
});
Expand Down Expand Up @@ -1069,6 +1089,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
expectTypeOf<Result['email']>().toEqualTypeOf<string>();
expectTypeOf<Result>().not.toHaveProperty('username');
expectTypeOf<Result>().not.toHaveProperty('posts');
expectTypeOf<Result>().not.toHaveProperty('phone');
});

it('select with a relation field (true) includes the relation', () => {
Expand All @@ -1084,6 +1105,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
expectTypeOf<Result>().toHaveProperty('id');
expectTypeOf<Result>().toHaveProperty('posts');
expectTypeOf<Result>().not.toHaveProperty('email');
expectTypeOf<Result>().not.toHaveProperty('phone');
});

it('select with nested options on a relation', () => {
Expand Down Expand Up @@ -1214,6 +1236,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
type Result = z.infer<typeof _schema>;
expectTypeOf<Result['id']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['email']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['phone']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['username']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['active']>().toEqualTypeOf<boolean | undefined>();
expectTypeOf<Result['age']>().toEqualTypeOf<number | undefined>();
Expand Down Expand Up @@ -1342,6 +1365,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
const _schema = factory.makeModelSchema('User', { optionality: 'all' });
type Result = z.infer<typeof _schema>;
expectTypeOf<Result['email']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['phone']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['username']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['age']>().toEqualTypeOf<number | undefined>();
// already-optional nullable field
Expand All @@ -1356,6 +1380,7 @@ describe('SchemaFactory - makeModelSchema with options', () => {
type Result = z.infer<typeof _schema>;
expectTypeOf<Result>().not.toHaveProperty('username');
expectTypeOf<Result['email']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['phone']>().toEqualTypeOf<string | undefined>();
});

it('infers selected fields as optional when optionality is all', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/zod/test/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export class SchemaType implements SchemaDef {
type: "String",
attributes: [{ name: "@email" }, { name: "@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("The user's email address") }] }] as readonly AttributeApplication[]
},
phone: {
name: "phone",
type: "String",
attributes: [{ name: "@phone" }] as readonly AttributeApplication[]
},
username: {
name: "username",
type: "String",
Expand Down
1 change: 1 addition & 0 deletions packages/zod/test/schema/schema.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Address {
model User {
id String @id @default(cuid())
email String @email @meta("description", "The user's email address")
phone String @phone
username String @length(3, 50)
website String? @url
code String @startsWith("USR")
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e/orm/validation/custom-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('Custom validation tests', () => {
str3 String?
str4 String?
str5 String?
str6 String?
int1 Int?
list1 Int[]
list2 Int[]
Expand All @@ -32,6 +33,8 @@ describe('Custom validation tests', () => {

@@validate(str5 == null || isDateTime(str5), 'invalid str5')

@@validate(str6 == null || isPhone(str6), 'invalid str6')

@@validate(list1 == null || (has(list1, 1) && hasSome(list1, [2, 3]) && hasEvery(list1, [4, 5])), 'invalid list1')

@@validate(list2 == null || isEmpty(list2), 'invalid list2', ['x', 'y'])
Expand Down Expand Up @@ -77,6 +80,9 @@ describe('Custom validation tests', () => {
// violates datetime
await expect(_t({ str5: 'not-an-datetime' })).toBeRejectedByValidation(['invalid str5']);

// violates phone
await expect(_t({ str6: 'not-a-phone' })).toBeRejectedByValidation(['invalid str6']);

// violates has
await expect(_t({ list1: [2, 3, 4, 5] })).toBeRejectedByValidation(['invalid list1']);

Expand Down Expand Up @@ -107,6 +113,7 @@ describe('Custom validation tests', () => {
str3: 'ab@c.com',
str4: 'http://a.b.c',
str5: new Date().toISOString(),
str6: '+15555555555',
int1: 2,
list1: [1, 2, 4, 5],
list2: [],
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e/orm/validation/toplevel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('Toplevel field validation tests', () => {
str4 String? @url
str5 String? @trim @lower
str6 String? @upper
str7 String? @phone
}
`,
);
Expand Down Expand Up @@ -83,6 +84,12 @@ describe('Toplevel field validation tests', () => {
} else {
await expect(_t({ str6: 'aBc' })).resolves.toMatchObject({ count: 1 });
}

// violates @phone
await expect(_t({ str7: 'not-a-phone' })).toBeRejectedByValidation(['Invalid E.164']);

// satisfies @phone
await expect(_t({ str7: '+15555555555' })).toResolveTruthy();
}
});

Expand Down
Loading