diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 920332208..101a738db 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -652,6 +652,7 @@ enum Status { score Float @gte(0.0) rating Decimal @lt(10) rank BigInt @lte(999) + extId String @uuid }`, ); runCli('db push', workDir); diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index aa62891de..e3d39c4c8 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -542,6 +542,11 @@ attribute @contains(_ text: String, _ message: String?) @@@targetField([StringFi */ attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation +/** + * Validates a string field value is a valid UUID. + */ +attribute @uuid(_ version: Int?, _ message: String?) @@@targetField([StringField]) @@@validation + /** * Validates a string field value is a valid email address. */ @@ -649,6 +654,12 @@ function isDate(field: String): Boolean { function isTime(field: String, precision: Int?): Boolean { } @@@expressionContext([ValidationRule]) +/** + * Validates a string field value is a valid UUID. + */ +function isUuid(field: String, version: Int?): Boolean { +} @@@expressionContext([ValidationRule]) + /** * Validates a string field value is a valid url. */ diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 4fa380599..bfeb251cc 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -19,6 +19,7 @@ import { isLiteralExpr, isMemberAccessExpr, isModel, + isNumberLiteral, isObjectExpr, isPlugin, isReferenceExpr, @@ -78,6 +79,13 @@ export function getStringLiteral(node: AstNode | undefined): string | undefined return isStringLiteral(node) ? node.value : undefined; } +/** + * Try getting number value from a potential number literal expression + */ +export function getNumberLiteral(node: AstNode | undefined): number | undefined { + return isNumberLiteral(node) ? Number(node.value) : undefined; +} + const isoDateTimeRegex = /^\d{4}(-\d\d(-\d\d(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?)?)?$/i; /** diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index d9568e9db..14017bd39 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -29,6 +29,7 @@ import { getAttributeArg, getContainingDataModel, getDataSourceProvider, + getNumberLiteral, getStringLiteral, hasAttribute, isAuthOrAuthMemberAccess, @@ -460,6 +461,14 @@ export default class AttributeApplicationValidator implements AstValidator(versionArg); + if (version !== undefined && version !== 4 && version !== 7) { + accept('error', 'second argument must be 4 or 7', { + node: expr.args[1]!, + }); + } + } + } + @func('cuid') private _checkCuid(expr: InvocationExpr, accept: ValidationAcceptor) { // first argument must be 1 or 2 if provided diff --git a/packages/language/test/attribute-application.test.ts b/packages/language/test/attribute-application.test.ts index 3e74e2484..f8983c0ee 100644 --- a/packages/language/test/attribute-application.test.ts +++ b/packages/language/test/attribute-application.test.ts @@ -588,6 +588,81 @@ describe('Attribute application validation tests', () => { }); }); + describe('Field-level @uuid attribute', () => { + it('does not require a version arg', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid + } + `, + ); + }); + + it('accepts supported version args', async () => { + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(4) + } + `, + ); + + await loadSchema( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(7) + } + `, + ); + }); + + it('rejects unsupported version args', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(1) + } + `, + /`@uuid` version must be `4` or `7`/, + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @uuid(2) + } + `, + /`@uuid` version must be `4` or `7`/, + ); + }); + }); + describe('Native type mapping attributes', () => { describe('sqlite', () => { it('rejects when any native type mapping attribute is used', async () => { diff --git a/packages/language/test/function-invocation.test.ts b/packages/language/test/function-invocation.test.ts index ff6bb45ef..68d20a092 100644 --- a/packages/language/test/function-invocation.test.ts +++ b/packages/language/test/function-invocation.test.ts @@ -414,4 +414,52 @@ describe('Function Invocation Tests', () => { ); }); }); + + describe('isUuid() version validation', () => { + it('should accept valid uuid versions', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid(4)) + + @@validate(isUuid(id, 4)) + } + `); + + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid(7)) + + @@validate(isUuid(id, 7)) + } + `); + }); + + it('should reject invalid uuid versions', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id @default(uuid()) + + @@validate(isUuid(id, 1)) + } + `, + 'second argument must be 4 or 7', + ); + }); + }); }); diff --git a/packages/zod/src/utils.ts b/packages/zod/src/utils.ts index a25202187..3aeb693b7 100644 --- a/packages/zod/src/utils.ts +++ b/packages/zod/src/utils.ts @@ -17,6 +17,7 @@ const stringFuncZodMap = { isEmail: 'email', isUrl: 'url', isPhone: 'e164', + isUuid: 'uuid', isDate: 'date', isTime: 'time', isDateTime: 'datetime', @@ -79,6 +80,15 @@ export function addStringValidation( } break; } + case '@uuid': { + const version = getArgValue(attr.args?.[0]?.value); + if (version === 7) { + result = result.uuidv7(); + } else { + result = result.uuidv4(); + } + break; + } case '@email': result = result.email(); break; @@ -555,6 +565,7 @@ function evalCall(data: any, expr: CallExpression) { case 'isEmail': case 'isUrl': case 'isPhone': + case 'isUuid': case 'isDate': case 'isTime': case 'isDateTime': { @@ -569,6 +580,13 @@ function evalCall(data: any, expr: CallExpression) { `"isTime" optional second argument must be a number`, ); return z.iso.time({ precision }).safeParse(fieldArg).success; + } else if (f === 'isUuid') { + const version = getArgValue(expr.args?.[1]); + invariant( + version === null || version == undefined || version === 4 || version === 7, + `"isUuid" optional second argument must 4 or 7`, + ); + return z.uuid({ version: version ? `v${version}` : undefined }).safeParse(fieldArg).success; } const fn = stringFuncZodMap[f]; return z.string()[fn]().safeParse(fieldArg).success; diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index a0bc7592c..695bc49ba 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -27,6 +27,7 @@ const validUser = { metadata: null, status: 'ACTIVE', address: null, + extId: null, }; // A fully valid Post object (without relations) @@ -285,6 +286,18 @@ describe('SchemaFactory - makeModelSchema', () => { expect(result.success).toBe(true); }); + it('rejects invalid uuid for @uuid field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, extId: 'not-a-uuid' }); + expect(result.success).toBe(false); + }); + + it('accepts valid uuid for @uuid field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, extId: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3' }); + 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' }); diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index fa7bc045c..ef36c9113 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -83,6 +83,12 @@ export class SchemaType implements SchemaDef { optional: true, attributes: [{ name: "@time" }] as readonly AttributeApplication[] }, + extId: { + name: "extId", + type: "String", + optional: true, + attributes: [{ name: "@uuid" }] as readonly AttributeApplication[] + }, createdAt: { name: "createdAt", type: "DateTime", diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index e7deb27aa..6aa265ad0 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -34,6 +34,7 @@ model User { active Boolean birthdate String? @date localTime String? @time + extId String? @uuid createdAt DateTime? avatar Bytes? metadata Json? diff --git a/tests/e2e/orm/validation/custom-validation.test.ts b/tests/e2e/orm/validation/custom-validation.test.ts index 47f12049d..0523c9fc5 100644 --- a/tests/e2e/orm/validation/custom-validation.test.ts +++ b/tests/e2e/orm/validation/custom-validation.test.ts @@ -15,6 +15,7 @@ describe('Custom validation tests', () => { str6 String? str7 String? str8 String? + str9 String? int1 Int? list1 Int[] list2 Int[] @@ -41,6 +42,8 @@ describe('Custom validation tests', () => { @@validate(str8 == null || isTime(str8), 'invalid str8') + @@validate(str9 == null || isUuid(str9), 'invalid str9') + @@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']) @@ -95,6 +98,9 @@ describe('Custom validation tests', () => { // violates time await expect(_t({ str8: 'not-a-time' })).toBeRejectedByValidation(['invalid str8']); + // violates uuid + await expect(_t({ str9: 'not-a-uuid' })).toBeRejectedByValidation(['invalid str9']); + // violates has await expect(_t({ list1: [2, 3, 4, 5] })).toBeRejectedByValidation(['invalid list1']); @@ -128,6 +134,7 @@ describe('Custom validation tests', () => { str6: '+15555555555', str7: '2000-01-01', str8: '03:15:00', + str9: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3', 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 fbfdbd92a..f1d2fcde5 100644 --- a/tests/e2e/orm/validation/toplevel.test.ts +++ b/tests/e2e/orm/validation/toplevel.test.ts @@ -18,6 +18,8 @@ describe('Toplevel field validation tests', () => { str8 String? @date str9 String? @time str10 String? @time(-1) + str11 String? @uuid + str12 String? @uuid(7) } `, ); @@ -111,6 +113,18 @@ describe('Toplevel field validation tests', () => { // satisfies @time(-1) await expect(_t({ str10: '03:15' })).toResolveTruthy(); + + // violates @uuid + await expect(_t({ str11: 'not-a-uuid' })).toBeRejectedByValidation(['Invalid UUID']); + + // satisfies @uuid + await expect(_t({ str11: '20ef31c8-a2c6-4dca-b87b-838e364ab4b3' })).toResolveTruthy(); + + // violates @uuid(7) + await expect(_t({ str12: 'not-a-uuid' })).toBeRejectedByValidation(['Invalid UUID']); + + // satisfies @uuid(7) + await expect(_t({ str12: '019ff964-2f1d-7668-9a76-8648f2af9146' })).toResolveTruthy(); } });