From 233f9d12238c9811c25f38d121bf3fbe73d6f3c0 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 6 Mar 2023 22:38:49 +0800 Subject: [PATCH 1/2] feat: support self relations --- .../validator/datamodel-validator.ts | 59 ++++- .../validation/datamodel-validation.test.ts | 76 +++++++ .../relation-to-one-filter.test.ts | 1 - .../tests/with-policy/self-relation.test.ts | 214 ++++++++++++++++++ 4 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 tests/integration/tests/with-policy/self-relation.test.ts diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index 873f7935d..2e97852ba 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -118,8 +118,13 @@ export default class DataModelValidator implements AstValidator { } if (!fields || !references) { - if (accept) { - accept('error', `Both "fields" and "references" must be provided`, { node: relAttr }); + if (this.isSelfRelation(field, name)) { + // self relations are partial + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations + } else { + if (accept) { + accept('error', `Both "fields" and "references" must be provided`, { node: relAttr }); + } } } else { // validate "fields" and "references" typing consistency @@ -157,6 +162,33 @@ export default class DataModelValidator implements AstValidator { return { attr: relAttr, name, fields, references, valid }; } + private isSelfRelation(field: DataModelField, relationName?: string) { + if (field.type.reference?.ref === field.$container) { + // field directly references back to its type + return true; + } + + if (relationName) { + // field's relation points to another type, and that type's opposite relation field + // points back + const oppositeModelFields = field.type.reference?.ref?.fields as DataModelField[]; + if (oppositeModelFields) { + for (const oppositeField of oppositeModelFields) { + const { name: oppositeRelationName } = this.parseRelation(oppositeField); + if ( + oppositeRelationName === relationName && + oppositeField.type.reference?.ref === field.$container + ) { + // found an opposite relation field that points back to this field's type + return true; + } + } + } + } + + return false; + } + private validateRelationField(field: DataModelField, accept: ValidationAcceptor) { const thisRelation = this.parseRelation(field, accept); if (!thisRelation.valid) { @@ -180,15 +212,20 @@ export default class DataModelValidator implements AstValidator { ); return; } else if (oppositeFields.length > 1) { - oppositeFields.forEach((f) => - accept( - 'error', - `Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${ - oppositeModel.name - }" refer to the same relation to model "${field.$container.name}"`, - { node: f } - ) - ); + oppositeFields.forEach((f) => { + if (this.isSelfRelation(f)) { + // self relations are partial + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations + } else { + accept( + 'error', + `Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${ + oppositeModel.name + }" refer to the same relation to model "${field.$container.name}"`, + { node: f } + ); + } + }); return; } diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts index 067f5b341..a0106b86c 100644 --- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts +++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts @@ -449,4 +449,80 @@ describe('Data Model Validation Tests', () => { } `); }); + + it('self relation', async () => { + // one-to-one + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#one-to-one-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + successorId Int? @unique + successor User? @relation("BlogOwnerHistory", fields: [successorId], references: [id]) + predecessor User? @relation("BlogOwnerHistory") + } + `); + + // one-to-many + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#one-to-many-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + teacherId Int? + teacher User? @relation("TeacherStudents", fields: [teacherId], references: [id]) + students User[] @relation("TeacherStudents") + } + `); + + // many-to-many + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#many-to-many-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + } + `); + + // many-to-many explicit + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#many-to-many-self-relations + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + followedBy Follows[] @relation("following") + following Follows[] @relation("follower") + } + + model Follows { + follower User @relation("follower", fields: [followerId], references: [id]) + followerId Int + following User @relation("following", fields: [followingId], references: [id]) + followingId Int + + @@id([followerId, followingId]) + } + `); + + // multiple self relations + // https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations#defining-multiple-self-relations-on-the-same-model + await loadModel(` + ${prelude} + model User { + id Int @id @default(autoincrement()) + name String? + teacherId Int? + teacher User? @relation("TeacherStudents", fields: [teacherId], references: [id]) + students User[] @relation("TeacherStudents") + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + } + `); + }); }); diff --git a/tests/integration/tests/with-policy/relation-to-one-filter.test.ts b/tests/integration/tests/with-policy/relation-to-one-filter.test.ts index 0eacdf38c..d305a33d4 100644 --- a/tests/integration/tests/with-policy/relation-to-one-filter.test.ts +++ b/tests/integration/tests/with-policy/relation-to-one-filter.test.ts @@ -3,7 +3,6 @@ import path from 'path'; describe('With Policy: relation to-one filter', () => { let origDir: string; - const suite = 'relation-to-one-filter'; beforeAll(async () => { origDir = path.resolve('.'); diff --git a/tests/integration/tests/with-policy/self-relation.test.ts b/tests/integration/tests/with-policy/self-relation.test.ts new file mode 100644 index 000000000..dc7cb96ca --- /dev/null +++ b/tests/integration/tests/with-policy/self-relation.test.ts @@ -0,0 +1,214 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('With Policy: self relations', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('one-to-one', async () => { + const { withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + value Int + successorId Int? @unique + successor User? @relation("BlogOwnerHistory", fields: [successorId], references: [id]) + predecessor User? @relation("BlogOwnerHistory") + + @@allow('create', value > 0) + @@allow('read', true) + } + ` + ); + + const db = withPolicy(); + + // create denied + await expect( + db.user.create({ + data: { + value: 0, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + successor: { + create: { + value: 0, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + successor: { + create: { + value: 1, + }, + }, + predecessor: { + create: { + value: 0, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + successor: { + create: { + value: 1, + }, + }, + predecessor: { + create: { + value: 1, + }, + }, + }, + }) + ).toResolveTruthy(); + }); + + it('one-to-many', async () => { + const { withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + value Int + teacherId Int? + teacher User? @relation("TeacherStudents", fields: [teacherId], references: [id]) + students User[] @relation("TeacherStudents") + + @@allow('create', value > 0) + @@allow('read', true) + } + ` + ); + + const db = withPolicy(); + + // create denied + await expect( + db.user.create({ + data: { + value: 0, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + teacher: { + create: { value: 0 }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + teacher: { + create: { value: 1 }, + }, + students: { + create: [{ value: 0 }, { value: 1 }], + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + teacher: { + create: { value: 1 }, + }, + students: { + create: [{ value: 1 }, { value: 2 }], + }, + }, + }) + ).toResolveTruthy(); + }); + + it('many-to-many', async () => { + const { withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + value Int + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + + @@allow('create', value > 0) + @@allow('read', true) + } + ` + ); + + const db = withPolicy(); + + // create denied + await expect( + db.user.create({ + data: { + value: 0, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + followedBy: { create: { value: 0 } }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + followedBy: { create: { value: 1 } }, + following: { create: [{ value: 0 }, { value: 1 }] }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ + data: { + value: 1, + followedBy: { create: { value: 1 } }, + following: { create: [{ value: 1 }, { value: 2 }] }, + }, + }) + ).toResolveTruthy(); + }); +}); From a947eac9d663e8b9662c25d7294d837ce5647020 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 6 Mar 2023 22:43:21 +0800 Subject: [PATCH 2/2] bump version --- package.json | 2 +- packages/language/package.json | 2 +- packages/next/package.json | 2 +- packages/plugins/react/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/testtools/package.json | 2 +- tests/integration/test-run/package-lock.json | 4 ++-- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 230040c20..e36b26de1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 44249884b..9d96602a0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/next/package.json b/packages/next/package.json index 31536a73f..ee966860d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index fc9e28759..699704058 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index b48eff4e0..2c1951a02 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a1d445a37..be3563ecf 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index f864b7251..991115654 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7f4a728c6..f975377ff 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 3023611b0..ddf7c2d13 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 4c7ae0716..524ee288f 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "license": "MIT", "dependencies": { "@types/bcryptjs": "^2.4.2", @@ -156,7 +156,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.57", + "version": "1.0.0-alpha.58", "hasInstallScript": true, "license": "MIT", "dependencies": {