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
22 changes: 12 additions & 10 deletions packages/cli/test/db/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
28 changes: 21 additions & 7 deletions packages/zod/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends string | number | boolean>(expr: Expression | undefined): T | undefined {
if (!expr || !ExpressionUtils.isLiteral(expr)) {
return undefined;
Expand Down Expand Up @@ -75,6 +85,14 @@ export function addStringValidation(
case '@phone':
result = result.e164();
break;
case '@date':
result = result.date();
break;
case '@time': {
const precision = getArgValue<number>(attr.args?.[0]?.value);
result = result.time({ precision });
break;
}
case '@datetime':
result = result.datetime();
break;
Expand Down Expand Up @@ -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
Expand Down
52 changes: 48 additions & 4 deletions packages/zod/test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const validUser = {
balance: 10.0,
active: true,
birthdate: null,
localTime: null,
createdAt: null,
avatar: null,
metadata: null,
status: 'ACTIVE',
Expand Down Expand Up @@ -50,6 +52,8 @@ describe('SchemaFactory - makeModelSchema', () => {
expectTypeOf<User['code']>().toEqualTypeOf<string>();
// optional string field (nullable + optional)
expectTypeOf<User['website']>().toEqualTypeOf<string | null | undefined>();
expectTypeOf<User['birthdate']>().toEqualTypeOf<string | null | undefined>();
expectTypeOf<User['localTime']>().toEqualTypeOf<string | null | undefined>();

// number fields (Int and Float both map to ZodNumber)
expectTypeOf<User['age']>().toEqualTypeOf<number>();
Expand All @@ -65,7 +69,7 @@ describe('SchemaFactory - makeModelSchema', () => {
expectTypeOf<User['active']>().toEqualTypeOf<boolean>();

// DateTime
expectTypeOf<User['birthdate']>().toEqualTypeOf<Date | null | undefined>();
expectTypeOf<User['createdAt']>().toEqualTypeOf<Date | null | undefined>();

// optional Bytes
expectTypeOf<User['avatar']>().toEqualTypeOf<Uint8Array | null | undefined>();
Expand Down Expand Up @@ -144,15 +148,15 @@ 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);
});

it('accepts DateTime as an ISO datetime string', () => {
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);
});
Expand Down Expand Up @@ -214,7 +218,7 @@ describe('SchemaFactory - makeModelSchema', () => {
it('infers correct input types for fields', () => {
const _userSchema = factory.makeModelSchema('User');
type UserInput = z.input<typeof _userSchema>;
expectTypeOf<UserInput['birthdate']>().toEqualTypeOf<Date | null | undefined>();
expectTypeOf<UserInput['createdAt']>().toEqualTypeOf<Date | null | undefined>();
expectTypeOf<UserInput['balance']>().toEqualTypeOf<Decimal>();
expectTypeOf<UserInput['avatar']>().toEqualTypeOf<Uint8Array | null | undefined>();
});
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -608,6 +648,8 @@ describe('SchemaFactory - makeTypeSchema', () => {
balance: 1,
active: true,
birthdate: null,
localTime: null,
createdAt: null,
avatar: null,
metadata: null,
status: 'ACTIVE',
Expand Down Expand Up @@ -1378,6 +1420,8 @@ describe('SchemaFactory - makeModelSchema with options', () => {
expectTypeOf<Result['age']>().toEqualTypeOf<number | undefined>();
// already-optional nullable field
expectTypeOf<Result['website']>().toEqualTypeOf<string | null | undefined>();
expectTypeOf<Result['birthdate']>().toEqualTypeOf<string | null | undefined>();
expectTypeOf<Result['localTime']>().toEqualTypeOf<string | null | undefined>();
});

it('infers omitted field absent even with optionality all', () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/zod/test/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
4 changes: 3 additions & 1 deletion packages/zod/test/schema/schema.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/e2e/orm/validation/custom-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ describe('Custom validation tests', () => {
str4 String?
str5 String?
str6 String?
str7 String?
str8 String?
int1 Int?
list1 Int[]
list2 Int[]
Expand All @@ -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'])
Expand Down Expand Up @@ -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']);

Expand Down Expand Up @@ -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: [],
Expand Down
37 changes: 29 additions & 8 deletions tests/e2e/orm/validation/toplevel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
`,
);
Expand Down Expand Up @@ -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();
}
});

Expand Down
Loading