diff --git a/TODO.md b/TODO.md index c63f4758..35c34c71 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,9 @@ - [x] migrate - [x] info - [x] init + - [x] validate + - [ ] format + - [ ] db seed - [ ] ORM - [x] Create - [x] Input validation diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index ec144c90..f7841166 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -224,6 +224,11 @@ attribute @@@prisma() */ attribute @@@completionHint(_ values: String[]) +/** + * Indicates that the attribute can only be applied once to a declaration. + */ +attribute @@@once() + /** * Defines a single-field ID on the model. * @@ -232,7 +237,7 @@ attribute @@@completionHint(_ values: String[]) * @param sort: Allows you to specify in what order the entries of the ID are stored in the database. The available options are Asc and Desc. * @param clustered: Defines whether the ID is clustered or non-clustered. Defaults to true. */ -attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef +attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef @@@once /** * Defines a default value for a field. @@ -247,7 +252,7 @@ attribute @default(_ value: ContextType, map: String?) @@@prisma @@@supportTypeD * @param sort: Allows you to specify in what order the entries of the constraint are stored in the database. The available options are Asc and Desc. * @param clustered: Boolean Defines whether the constraint is clustered or non-clustered. Defaults to false. */ -attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma +attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@once /** * Defines a multi-field ID (composite ID) on the model. diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 285f917f..df6f5334 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -75,6 +75,8 @@ export default class AttributeApplicationValidator implements AstValidator(); for (const arg of attr.args) { @@ -131,6 +133,18 @@ export default class AttributeApplicationValidator implements AstValidator a.decl.ref?.name === '@@@once')) { + return; + } + + const duplicates = attr.$container.attributes.filter((a) => a.decl.ref === attrDecl && a !== attr); + if (duplicates.length > 0) { + accept('error', `Attribute "${attrDecl.name}" can only be applied once`, { node: attr }); + } + } + @check('@@allow') @check('@@deny') // @ts-expect-error @@ -197,13 +211,21 @@ export default class AttributeApplicationValidator implements AstValidator { if (!isReferenceExpr(item)) { accept('error', `Expecting a field reference`, { diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index fd23c290..f163d2a3 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -259,11 +259,22 @@ export default class DataModelValidator implements AstValidator { return; } + if (this.isSelfRelation(field)) { + if (!thisRelation.name) { + accept('error', 'Self-relation field must have a name in @relation attribute', { + node: field, + }); + return; + } + } + const oppositeModel = field.type.reference!.ref! as DataModel; // Use name because the current document might be updated let oppositeFields = getModelFieldsWithBases(oppositeModel, false).filter( - (f) => f.type.reference?.ref?.name === contextModel.name, + (f) => + f !== field && // exclude self in case of self relation + f.type.reference?.ref?.name === contextModel.name, ); oppositeFields = oppositeFields.filter((f) => { const fieldRel = this.parseRelation(f); @@ -322,27 +333,41 @@ export default class DataModelValidator implements AstValidator { let relationOwner: DataModelField; - if (thisRelation?.references?.length && thisRelation.fields?.length) { - if (oppositeRelation?.references || oppositeRelation?.fields) { - accept('error', '"fields" and "references" must be provided only on one side of relation field', { - node: oppositeField, - }); - return; - } else { - relationOwner = oppositeField; - } - } else if (oppositeRelation?.references?.length && oppositeRelation.fields?.length) { - if (thisRelation?.references || thisRelation?.fields) { - accept('error', '"fields" and "references" must be provided only on one side of relation field', { - node: field, - }); - return; - } else { - relationOwner = field; + if (field.type.array && oppositeField.type.array) { + // if both the field is array, then it's an implicit many-to-many relation, + // neither side should have fields/references + for (const r of [thisRelation, oppositeRelation]) { + if (r.fields?.length || r.references?.length) { + accept( + 'error', + 'Implicit many-to-many relation cannot have "fields" or "references" in @relation attribute', + { + node: r === thisRelation ? field : oppositeField, + }, + ); + } } } else { - // if both the field is array, then it's an implicit many-to-many relation - if (!(field.type.array && oppositeField.type.array)) { + if (thisRelation?.references?.length && thisRelation.fields?.length) { + if (oppositeRelation?.references || oppositeRelation?.fields) { + accept('error', '"fields" and "references" must be provided only on one side of relation field', { + node: oppositeField, + }); + return; + } else { + relationOwner = oppositeField; + } + } else if (oppositeRelation?.references?.length && oppositeRelation.fields?.length) { + if (thisRelation?.references || thisRelation?.fields) { + accept('error', '"fields" and "references" must be provided only on one side of relation field', { + node: field, + }); + return; + } else { + relationOwner = field; + } + } else { + // for non-M2M relations, one side must have fields/references [field, oppositeField].forEach((f) => { if (!this.isSelfRelation(f)) { accept( @@ -352,56 +377,60 @@ export default class DataModelValidator implements AstValidator { ); } }); + return; } - return; - } - - if (!relationOwner.type.array && !relationOwner.type.optional) { - accept('error', 'Relation field needs to be list or optional', { - node: relationOwner, - }); - return; - } - if (relationOwner !== field && !relationOwner.type.array) { - // one-to-one relation requires defining side's reference field to be @unique - // e.g.: - // model User { - // id String @id @default(cuid()) - // data UserData? - // } - // model UserData { - // id String @id @default(cuid()) - // user User @relation(fields: [userId], references: [id]) - // userId String - // } - // - // UserData.userId field needs to be @unique - - const containingModel = field.$container as DataModel; - const uniqueFieldList = getUniqueFields(containingModel); - - // field is defined in the abstract base model - if (containingModel !== contextModel) { - uniqueFieldList.push(...getUniqueFields(contextModel)); + if (!relationOwner.type.array && !relationOwner.type.optional) { + accept('error', 'Relation field needs to be list or optional', { + node: relationOwner, + }); + return; } - thisRelation.fields?.forEach((ref) => { - const refField = ref.target.ref as DataModelField; - if (refField) { - if (refField.attributes.find((a) => a.decl.ref?.name === '@id' || a.decl.ref?.name === '@unique')) { - return; - } - if (uniqueFieldList.some((list) => list.includes(refField))) { - return; - } - accept( - 'error', - `Field "${refField.name}" on model "${containingModel.name}" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`, - { node: refField }, - ); + if (relationOwner !== field && !relationOwner.type.array) { + // one-to-one relation requires defining side's reference field to be @unique + // e.g.: + // model User { + // id String @id @default(cuid()) + // data UserData? + // } + // model UserData { + // id String @id @default(cuid()) + // user User @relation(fields: [userId], references: [id]) + // userId String + // } + // + // UserData.userId field needs to be @unique + + const containingModel = field.$container as DataModel; + const uniqueFieldList = getUniqueFields(containingModel); + + // field is defined in the abstract base model + if (containingModel !== contextModel) { + uniqueFieldList.push(...getUniqueFields(contextModel)); } - }); + + thisRelation.fields?.forEach((ref) => { + const refField = ref.target.ref as DataModelField; + if (refField) { + if ( + refField.attributes.find( + (a) => a.decl.ref?.name === '@id' || a.decl.ref?.name === '@unique', + ) + ) { + return; + } + if (uniqueFieldList.some((list) => list.includes(refField))) { + return; + } + accept( + 'error', + `Field "${refField.name}" on model "${containingModel.name}" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`, + { node: refField }, + ); + } + }); + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36af9eb9..6f3319d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,10 @@ importers: '@zenstackhq/testtools': specifier: workspace:* version: link:../../packages/testtools + devDependencies: + '@zenstackhq/cli': + specifier: workspace:* + version: link:../../packages/cli packages: diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 1ecc87b9..9cd3da90 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -7,5 +7,8 @@ }, "dependencies": { "@zenstackhq/testtools": "workspace:*" + }, + "devDependencies": { + "@zenstackhq/cli": "workspace:*" } } diff --git a/tests/e2e/prisma-consistency/zmodel-validation.test.ts b/tests/e2e/prisma-consistency/zmodel-validation.test.ts new file mode 100644 index 00000000..24730512 --- /dev/null +++ b/tests/e2e/prisma-consistency/zmodel-validation.test.ts @@ -0,0 +1,1012 @@ +import { execSync } from 'child_process'; +import { randomUUID } from 'crypto'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +interface ValidationResult { + success: boolean; + errors: string[]; +} + +class ZenStackValidationTester { + private testDir: string; + private schemaPath: string; + private cliPath: string; + + constructor(testDir: string) { + this.testDir = testDir; + this.schemaPath = join(testDir, 'zenstack', 'schema.zmodel'); + + // Get path relative to current test file + const currentDir = dirname(fileURLToPath(import.meta.url)); + this.cliPath = join(currentDir, '../node_modules/@zenstackhq/cli/bin/cli'); + } + + private setupTestDirectory() { + if (existsSync(this.testDir)) { + rmSync(this.testDir, { recursive: true, force: true }); + } + mkdirSync(this.testDir, { recursive: true }); + mkdirSync(join(this.testDir, 'zenstack'), { recursive: true }); + + // Create package.json + writeFileSync( + join(this.testDir, 'package.json'), + JSON.stringify( + { + name: 'zenstack-validation-test', + version: '1.0.0', + private: true, + }, + null, + 2, + ), + ); + } + + public runValidation(schema: string): ValidationResult { + this.setupTestDirectory(); + writeFileSync(this.schemaPath, schema); + + try { + execSync(`node ${this.cliPath} generate`, { + cwd: this.testDir, + stdio: 'pipe', + encoding: 'utf8', + }); + + return { + success: true, + errors: [], + }; + } catch (error: any) { + return { + success: false, + errors: this.extractErrors(error.stderr), + }; + } + } + + private extractErrors(output: string): string[] { + const lines = output.split('\n'); + const errors: string[] = []; + + for (const line of lines) { + if (line.includes('Error:') || line.includes('error:') || line.includes('✖')) { + errors.push(line.trim()); + } + } + + return errors; + } + + public cleanup() { + if (existsSync(this.testDir)) { + rmSync(this.testDir, { recursive: true, force: true }); + } + } +} + +describe('ZenStack validation consistency with Prisma', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), 'zenstack-validation-test-' + randomUUID()); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + describe('basic_models', () => { + it('should accept valid basic model with id field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject model without any unique criterion', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + email String + name String? +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject model with multiple @id fields', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @id + name String? +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject model with both @id field and @@id', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + + @@id([firstName, lastName]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject optional ID field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int? @id @default(autoincrement()) + email String @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject array ID field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int[] @id + email String @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('compound_ids', () => { + it('should accept valid compound ID with @@id', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + firstName String + lastName String + age Int + + @@id([firstName, lastName]) +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject empty compound ID', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + firstName String + lastName String + + @@id([]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('field_types', () => { + it('should reject optional array field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + tags String[]? +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject array field with SQLite', () => { + const result = tester.runValidation(` +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id Int @id @default(autoincrement()) + tags String[] +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept array field with PostgreSQL', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + tags String[] +} + `); + + expect(result.success).toBe(true); + }); + }); + + describe('relations_one_to_one', () => { + it('should accept valid one-to-one relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject one-to-one relation without @unique on FK', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject one-to-one relation missing opposite field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + userId Int @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject one-to-one with both sides required', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('relations_one_to_many', () => { + it('should accept valid one-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject one-to-many without @relation annotation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject one-to-many relation referencing non-existent FK field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('relations_many_to_many', () => { + it('should accept valid implicit many-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors User[] +} + `); + + expect(result.success).toBe(true); + }); + + it('should accept valid explicit many-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts PostUser[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors PostUser[] +} + +model PostUser { + user User @relation(fields: [userId], references: [id]) + post Post @relation(fields: [postId], references: [id]) + userId Int + postId Int + + @@id([userId, postId]) +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject implicit many-to-many with explicit @relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] @relation(fields: [id], references: [id]) +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors User[] +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('relations_self', () => { + it('should accept valid self relation with proper naming', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + manager User? @relation("UserManager", fields: [managerId], references: [id]) + managerId Int? + employees User[] @relation("UserManager") +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject self relation without relation name', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + manager User? @relation(fields: [managerId], references: [id]) + managerId Int? + employees User[] +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept self many-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + following User[] @relation("UserFollows") + followers User[] @relation("UserFollows") +} + `); + + expect(result.success).toBe(true); + }); + }); + + describe('relation_validation', () => { + it('should reject mismatched length of fields and references arrays', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id, email]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject empty fields array', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject empty references array', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: []) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject partial relation specification with only fields', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject partial relation specification with only references', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject both sides of relation with fields/references', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] @relation(fields: [id], references: [authorId]) +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject type mismatch between fields and references', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('unique_constraints', () => { + it('should accept valid compound unique constraint', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String @unique + + @@unique([firstName, lastName]) +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject empty unique constraint', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + + @@unique([]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept unique constraint on optional field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String? @unique + name String +} + `); + + expect(result.success).toBe(true); + }); + }); + + describe('enums', () => { + it('should accept valid enum definition and usage', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { + USER + ADMIN + MODERATOR +} + +model User { + id Int @id @default(autoincrement()) + role Role @default(USER) + name String +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject empty enum', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { +} + +model User { + id Int @id @default(autoincrement()) + role Role @default(USER) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('datasource', () => { + it('should reject multiple datasources', () => { + const result = tester.runValidation(` +datasource db1 { + provider = "postgresql" + url = env("DATABASE_URL") +} + +datasource db2 { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject missing datasource', () => { + const result = tester.runValidation(` +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject invalid provider', () => { + const result = tester.runValidation(` +datasource db { + provider = "nosql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('attributes', () => { + it('should reject duplicate field attributes', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique @unique + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject invalid default value type', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @default(123) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept valid @map attribute', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique @map("email_address") + name String + + @@map("users") +} + `); + + expect(result.success).toBe(true); + }); + }); +});