diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 811c20ccf..58649e3da 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -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]+$') diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 761e577a4..5081aee6b 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -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. */ @@ -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 ////////////////////////////////////////////// diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index 9081c1c86..f21d9196f 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -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; @@ -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 diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 0dd616c2d..f221d2b64 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -11,6 +11,7 @@ const factory = createSchemaFactory(schema); const validUser = { id: 'user123', email: 'test@example.com', + phone: '+15555555555', username: 'johndoe', website: null, code: 'USR001', @@ -44,6 +45,7 @@ describe('SchemaFactory - makeModelSchema', () => { // required string fields expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); // optional string field (nullable + optional) @@ -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); + }); + + 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' }); @@ -576,6 +590,7 @@ describe('SchemaFactory - makeTypeSchema', () => { const validUser = { id: 'u1', email: 'a@b.com', + phone: '+15555555555', username: 'alice', website: null, code: 'USR01', @@ -934,7 +949,9 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().toHaveProperty('id'); expectTypeOf().toEqualTypeOf(); expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('phone'); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it('omit: {} (empty) keeps all scalar fields', () => { @@ -954,6 +971,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().not.toHaveProperty('username'); expectTypeOf().not.toHaveProperty('avatar'); expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('phone'); }); }); @@ -985,6 +1003,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { type Result = z.infer; expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); @@ -1039,6 +1058,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { type Result = z.infer; expectTypeOf().not.toHaveProperty('username'); expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('phone'); expectTypeOf().toHaveProperty('posts'); }); }); @@ -1069,6 +1089,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().not.toHaveProperty('username'); expectTypeOf().not.toHaveProperty('posts'); + expectTypeOf().not.toHaveProperty('phone'); }); it('select with a relation field (true) includes the relation', () => { @@ -1084,6 +1105,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().toHaveProperty('id'); expectTypeOf().toHaveProperty('posts'); expectTypeOf().not.toHaveProperty('email'); + expectTypeOf().not.toHaveProperty('phone'); }); it('select with nested options on a relation', () => { @@ -1214,6 +1236,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { type Result = z.infer; expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -1342,6 +1365,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { const _schema = factory.makeModelSchema('User', { optionality: 'all' }); type Result = z.infer; expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); // already-optional nullable field @@ -1356,6 +1380,7 @@ describe('SchemaFactory - makeModelSchema with options', () => { type Result = z.infer; expectTypeOf().not.toHaveProperty('username'); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it('infers selected fields as optional when optionality is all', () => { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index e0dff2a49..3b8dd6eb2 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -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", diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index 9e0d5716a..0fc3aec88 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -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") diff --git a/tests/e2e/orm/validation/custom-validation.test.ts b/tests/e2e/orm/validation/custom-validation.test.ts index 35df71b60..905c99c92 100644 --- a/tests/e2e/orm/validation/custom-validation.test.ts +++ b/tests/e2e/orm/validation/custom-validation.test.ts @@ -12,6 +12,7 @@ describe('Custom validation tests', () => { str3 String? str4 String? str5 String? + str6 String? int1 Int? list1 Int[] list2 Int[] @@ -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']) @@ -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']); @@ -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: [], diff --git a/tests/e2e/orm/validation/toplevel.test.ts b/tests/e2e/orm/validation/toplevel.test.ts index fab16d636..65927e192 100644 --- a/tests/e2e/orm/validation/toplevel.test.ts +++ b/tests/e2e/orm/validation/toplevel.test.ts @@ -14,6 +14,7 @@ describe('Toplevel field validation tests', () => { str4 String? @url str5 String? @trim @lower str6 String? @upper + str7 String? @phone } `, ); @@ -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(); } });