diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 58649e3da..7ad6a5899 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -619,16 +619,18 @@ enum Status { it('should preserve field-level validation attributes after db pull', async () => { const { workDir, schema } = await createProject( `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]+$') - age Int @gt(0) - score Float @gte(0.0) - rating Decimal @lt(10) - rank BigInt @lte(999) + id Int @id @default(autoincrement()) + email String @unique @email + phone String @phone + birthdate String @date + localTime String @time + name String @length(min: 2, max: 100) + website String? @url + code String? @regex('^[A-Z]+$') + age Int @gt(0) + score Float @gte(0.0) + rating Decimal @lt(10) + rank BigInt @lte(999) }`, ); runCli('db push', workDir); diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 4561da14a..0529c4f31 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -546,6 +546,16 @@ attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation */ attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation +/** + * Validates a string field value is a valid ISO date. + */ +attribute @date(_ message: String?) @@@targetField([StringField]) @@@validation + +/** + * Validates a string field value is a valid ISO time. + */ +attribute @time(_ precision: Int?, _ message: String?) @@@targetField([StringField]) @@@validation + /** * Validates a string field value is a valid url. */ @@ -621,6 +631,18 @@ function isEmail(field: String): Boolean { function isDateTime(field: String): Boolean { } @@@expressionContext([ValidationRule]) +/** + * Validates a string field value is a valid ISO date. + */ +function isDate(field: String): Boolean { +} @@@expressionContext([ValidationRule]) + +/** + * Validates a string field value is a valid ISO time. + */ +function isTime(field: String): Boolean { +} @@@expressionContext([ValidationRule]) + /** * Validates a string field value is a valid url. */ diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index f21d9196f..182e06683 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -12,6 +12,16 @@ import Decimal from 'decimal.js'; import { z } from 'zod'; import { SchemaFactoryError } from './error'; +// z.string()[mapped] +const stringFuncZodMap = { + isEmail: 'email', + isUrl: 'url', + isPhone: 'e164', + isDate: 'date', + isTime: 'time', + isDateTime: 'datetime', +} as const; + function getArgValue(expr: Expression | undefined): T | undefined { if (!expr || !ExpressionUtils.isLiteral(expr)) { return undefined; @@ -75,6 +85,14 @@ export function addStringValidation( case '@phone': result = result.e164(); break; + case '@date': + result = result.date(); + break; + case '@time': { + const precision = getArgValue(attr.args?.[0]?.value); + result = result.time({ precision }); + break; + } case '@datetime': result = result.datetime(); break; @@ -537,18 +555,14 @@ function evalCall(data: any, expr: CallExpression) { case 'isEmail': case 'isUrl': case 'isPhone': + case 'isDate': + case 'isTime': 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) - : f === 'isPhone' - ? ('e164' as const) - : ('datetime' as const); + const fn = stringFuncZodMap[f]; 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 8e81b69ea..a0bc7592c 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -21,6 +21,8 @@ const validUser = { balance: 10.0, active: true, birthdate: null, + localTime: null, + createdAt: null, avatar: null, metadata: null, status: 'ACTIVE', @@ -50,6 +52,8 @@ describe('SchemaFactory - makeModelSchema', () => { expectTypeOf().toEqualTypeOf(); // optional string field (nullable + optional) expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); // number fields (Int and Float both map to ZodNumber) expectTypeOf().toEqualTypeOf(); @@ -65,7 +69,7 @@ describe('SchemaFactory - makeModelSchema', () => { expectTypeOf().toEqualTypeOf(); // DateTime - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); // optional Bytes expectTypeOf().toEqualTypeOf(); @@ -144,7 +148,7 @@ describe('SchemaFactory - makeModelSchema', () => { it('accepts DateTime as a Date object', () => { const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, birthdate: new Date() }); + const result = userSchema.safeParse({ ...validUser, createdAt: new Date() }); expect(result.success).toBe(true); }); @@ -152,7 +156,7 @@ describe('SchemaFactory - makeModelSchema', () => { const userSchema = factory.makeModelSchema('User'); const result = userSchema.safeParse({ ...validUser, - birthdate: '2024-01-15T10:30:00.000Z', + createdAt: '2024-01-15T10:30:00.000Z', }); expect(result.success).toBe(true); }); @@ -214,7 +218,7 @@ describe('SchemaFactory - makeModelSchema', () => { it('infers correct input types for fields', () => { const _userSchema = factory.makeModelSchema('User'); type UserInput = z.input; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); @@ -281,6 +285,42 @@ describe('SchemaFactory - makeModelSchema', () => { expect(result.success).toBe(true); }); + it('rejects invalid date for @date field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, birthdate: 'not-a-date' }); + expect(result.success).toBe(false); + }); + + it('accepts valid date for @date field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, birthdate: '2000-01-01' }); + expect(result.success).toBe(true); + }); + + it('accepts null for optional @date field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, birthdate: null }); + expect(result.success).toBe(true); + }); + + it('rejects invalid time for @time field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, localTime: 'not-a-time' }); + expect(result.success).toBe(false); + }); + + it('accepts valid time for @time field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, localTime: '03:15:00' }); + expect(result.success).toBe(true); + }); + + it('accepts null for optional @time field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, localTime: null }); + 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' }); @@ -608,6 +648,8 @@ describe('SchemaFactory - makeTypeSchema', () => { balance: 1, active: true, birthdate: null, + localTime: null, + createdAt: null, avatar: null, metadata: null, status: 'ACTIVE', @@ -1378,6 +1420,8 @@ describe('SchemaFactory - makeModelSchema with options', () => { expectTypeOf().toEqualTypeOf(); // already-optional nullable field expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it('infers omitted field absent even with optionality all', () => { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index 3b8dd6eb2..fa7bc045c 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -73,6 +73,18 @@ export class SchemaType implements SchemaDef { }, birthdate: { name: "birthdate", + type: "String", + optional: true, + attributes: [{ name: "@date" }] as readonly AttributeApplication[] + }, + localTime: { + name: "localTime", + type: "String", + optional: true, + attributes: [{ name: "@time" }] as readonly AttributeApplication[] + }, + createdAt: { + name: "createdAt", type: "DateTime", optional: true }, diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index 0fc3aec88..e7deb27aa 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -32,7 +32,9 @@ model User { bigNum BigInt @gte(0) balance Decimal @gt(0) active Boolean - birthdate DateTime? + birthdate String? @date + localTime String? @time + createdAt DateTime? avatar Bytes? metadata Json? status Status diff --git a/tests/e2e/orm/validation/custom-validation.test.ts b/tests/e2e/orm/validation/custom-validation.test.ts index 905c99c92..47f12049d 100644 --- a/tests/e2e/orm/validation/custom-validation.test.ts +++ b/tests/e2e/orm/validation/custom-validation.test.ts @@ -13,6 +13,8 @@ describe('Custom validation tests', () => { str4 String? str5 String? str6 String? + str7 String? + str8 String? int1 Int? list1 Int[] list2 Int[] @@ -35,6 +37,10 @@ describe('Custom validation tests', () => { @@validate(str6 == null || isPhone(str6), 'invalid str6') + @@validate(str7 == null || isDate(str7), 'invalid str7') + + @@validate(str8 == null || isTime(str8), 'invalid str8') + @@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']) @@ -83,6 +89,12 @@ describe('Custom validation tests', () => { // violates phone await expect(_t({ str6: 'not-a-phone' })).toBeRejectedByValidation(['invalid str6']); + // violates date + await expect(_t({ str7: 'not-a-date' })).toBeRejectedByValidation(['invalid str7']); + + // violates time + await expect(_t({ str8: 'not-a-time' })).toBeRejectedByValidation(['invalid str8']); + // violates has await expect(_t({ list1: [2, 3, 4, 5] })).toBeRejectedByValidation(['invalid list1']); @@ -114,6 +126,8 @@ describe('Custom validation tests', () => { str4: 'http://a.b.c', str5: new Date().toISOString(), str6: '+15555555555', + str7: '2000-01-01', + str8: '03:15:00', 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 65927e192..fbfdbd92a 100644 --- a/tests/e2e/orm/validation/toplevel.test.ts +++ b/tests/e2e/orm/validation/toplevel.test.ts @@ -7,14 +7,17 @@ describe('Toplevel field validation tests', () => { const db = await createTestClient( ` model Foo { - id Int @id @default(autoincrement()) - str1 String? @length(2, 4) @startsWith('a') @endsWith('b') @contains('m') @regex('b{2}') - str2 String? @email - str3 String? @datetime - str4 String? @url - str5 String? @trim @lower - str6 String? @upper - str7 String? @phone + id Int @id @default(autoincrement()) + str1 String? @length(2, 4) @startsWith('a') @endsWith('b') @contains('m') @regex('b{2}') + str2 String? @email + str3 String? @datetime + str4 String? @url + str5 String? @trim @lower + str6 String? @upper + str7 String? @phone + str8 String? @date + str9 String? @time + str10 String? @time(-1) } `, ); @@ -90,6 +93,24 @@ describe('Toplevel field validation tests', () => { // satisfies @phone await expect(_t({ str7: '+15555555555' })).toResolveTruthy(); + + // violates @date + await expect(_t({ str8: 'not-a-date' })).toBeRejectedByValidation(['Invalid ISO date']); + + // satisfies @date + await expect(_t({ str8: '2000-01-01' })).toResolveTruthy(); + + // violates @time + await expect(_t({ str9: 'not-a-time' })).toBeRejectedByValidation(['Invalid ISO time']); + + // satisfies @time + await expect(_t({ str9: '03:15:00' })).toResolveTruthy(); + + // violates @time(-1) + await expect(_t({ str10: '03:15:00' })).toBeRejectedByValidation(['Invalid ISO time']); + + // satisfies @time(-1) + await expect(_t({ str10: '03:15' })).toResolveTruthy(); } });