diff --git a/package.json b/package.json index 9631afe17..8a23bbd4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 3604446c4..e52c47113 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index cdb896de0..5f2cbbecb 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 99fa6cccc..2d7f9086c 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 9cfa6d24f..657460b4d 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 3a52cbf82..c6c5a3732 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-beta.16", + "version": "1.0.0-beta.17", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 5b4c40678..d8eae6123 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-beta.16", + "version": "1.0.0-beta.17", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 7508760fa..de1fc1f1f 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -489,16 +489,21 @@ export class PolicyUtil { } // inject extra condition for to-many or nullable to-one relation await this.injectAuthGuard(injectTarget[field], fieldInfo.type, 'read'); + + // recurse + const subHoisted = await this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]); + if (subHoisted.length > 0) { + // we can convert it to a where at this level + injectTarget[field].where = this.and(injectTarget[field].where, ...subHoisted); + } } else { // hoist non-nullable to-one filter to the parent level hoisted = this.getAuthGuard(fieldInfo.type, 'read'); - } - - // recurse - const subHoisted = await this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]); - - if (subHoisted.length > 0) { - hoisted = this.and(hoisted, ...subHoisted); + // recurse + const subHoisted = await this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]); + if (subHoisted.length > 0) { + hoisted = this.and(hoisted, ...subHoisted); + } } if (hoisted && !this.isTrue(hoisted)) { diff --git a/packages/schema/build/bundle.js b/packages/schema/build/bundle.js index fb683cb6d..9adab015b 100644 --- a/packages/schema/build/bundle.js +++ b/packages/schema/build/bundle.js @@ -8,7 +8,7 @@ require('esbuild') entryPoints: ['src/extension.ts', 'src/language-server/main.ts'], outdir: 'bundle', bundle: true, - external: ['vscode'], + external: ['vscode', '@prisma/*'], platform: 'node', sourcemap: !minify, watch: watch diff --git a/packages/schema/package.json b/packages/schema/package.json index 8e15e68f4..8e511030a 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-beta.16", + "version": "1.0.0-beta.17", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ba3fcefaf..5f6ab4fc8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { @@ -29,6 +29,7 @@ "ts-morph": "^16.0.0" }, "devDependencies": { + "@types/node": "^18.0.0", "@types/semver": "^7.3.13", "copyfiles": "^2.4.1", "rimraf": "^3.0.2", diff --git a/packages/sdk/src/prisma.ts b/packages/sdk/src/prisma.ts index 81ce71f7a..4b4e461a1 100644 --- a/packages/sdk/src/prisma.ts +++ b/packages/sdk/src/prisma.ts @@ -1,6 +1,6 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + import type { DMMF } from '@prisma/generator-helper'; -import { getDMMF as getDMMF4 } from '@prisma/internals'; -import { getDMMF as getDMMF5 } from '@prisma/internals-v5'; import { getPrismaVersion } from '@zenstackhq/runtime'; import path from 'path'; import * as semver from 'semver'; @@ -84,8 +84,10 @@ export type GetDMMFOptions = { export function getDMMF(options: GetDMMFOptions): Promise { const prismaVersion = getPrismaVersion(); if (prismaVersion && semver.gte(prismaVersion, '5.0.0')) { - return getDMMF5(options); + const _getDMMF = require('@prisma/internals-v5').getDMMF; + return _getDMMF(options); } else { - return getDMMF4(options); + const _getDMMF = require('@prisma/internals').getDMMF; + return _getDMMF(options); } } diff --git a/packages/server/package.json b/packages/server/package.json index 7239f2da5..cd599c5e9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 05fc78a90..465021a46 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-beta.16", + "version": "1.0.0-beta.17", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b673ed490..396a09cb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,7 +121,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) + version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) typescript: specifier: ^4.9.5 version: 4.9.5 @@ -192,7 +192,7 @@ importers: version: 2.0.3(react@18.2.0) ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4) + version: 29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4) typescript: specifier: ^4.9.4 version: 4.9.4 @@ -620,6 +620,9 @@ importers: specifier: ^16.0.0 version: 16.0.0 devDependencies: + '@types/node': + specifier: ^18.0.0 + version: 18.0.0 '@types/semver': specifier: ^7.3.13 version: 7.5.0 @@ -10771,7 +10774,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): + /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -10802,7 +10805,7 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.4 + typescript: 4.9.5 yargs-parser: 21.1.1 dev: true @@ -10841,41 +10844,6 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): - resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/types': ^29.0.0 - babel-jest: ^29.0.0 - esbuild: '*' - jest: ^29.0.0 - typescript: '>=4.3' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - dependencies: - '@babel/core': 7.22.9 - bs-logger: 0.2.6 - esbuild: 0.18.13 - fast-json-stable-stringify: 2.1.0 - jest: 29.5.0(@types/node@18.0.0) - jest-util: 29.5.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.5.3 - typescript: 4.9.5 - yargs-parser: 21.1.1 - dev: true - /ts-morph@16.0.0: resolution: {integrity: sha512-jGNF0GVpFj0orFw55LTsQxVYEUOCWBAbR5Ls7fTYE5pQsbW18ssTb/6UXx/GYAEjS+DQTp8VoTw0vqYMiaaQuw==} dependencies: diff --git a/tests/integration/tests/enhancements/with-policy/nested-to-many.test.ts b/tests/integration/tests/enhancements/with-policy/nested-to-many.test.ts index d0511dd5f..fa7059faa 100644 --- a/tests/integration/tests/enhancements/with-policy/nested-to-many.test.ts +++ b/tests/integration/tests/enhancements/with-policy/nested-to-many.test.ts @@ -61,6 +61,88 @@ describe('With Policy:nested to-many', () => { expect(read.m2).toHaveLength(2); }); + it('read condition hoisting', async () => { + const { withPolicy } = await loadSchema( + ` + model M1 { + id String @id @default(uuid()) + m2 M2[] + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int + + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id String + + m3 M3 @relation(fields: [m3Id], references:[id]) + m3Id String @unique + + m4 M4 @relation(fields: [m4Id], references:[id]) + m4Id String + + @@allow('create', true) + @@allow('read', value > 0) + } + + model M3 { + id String @id @default(uuid()) + value Int + m2 M2? + + @@allow('create', true) + @@allow('read', value > 1) + } + + model M4 { + id String @id @default(uuid()) + value Int + m2 M2[] + + @@allow('create', true) + @@allow('read', value > 1) + } + ` + ); + + const db = withPolicy(); + + await db.m1.create({ + include: { m2: true }, + data: { + id: '1', + m2: { + create: [ + { id: 'm2-1', value: 1, m3: { create: { value: 1 } }, m4: { create: { value: 1 } } }, + { id: 'm2-2', value: 1, m3: { create: { value: 2 } }, m4: { create: { value: 2 } } }, + ], + }, + }, + }); + + let read = await db.m1.findFirst({ include: { m2: true } }); + expect(read.m2).toHaveLength(2); + read = await db.m1.findFirst({ select: { m2: { select: { id: true } } } }); + expect(read.m2).toHaveLength(2); + + // check m2-m3 filtering + // including m3 causes m2 to be filtered since m3 is not nullable + read = await db.m1.findFirst({ include: { m2: { include: { m3: true } } } }); + expect(read.m2).toHaveLength(1); + read = await db.m1.findFirst({ select: { m2: { select: { m3: true } } } }); + expect(read.m2).toHaveLength(1); + + // check m2-m4 filtering + // including m3 causes m2 to be filtered since m4 is not nullable + read = await db.m1.findFirst({ include: { m2: { include: { m4: true } } } }); + expect(read.m2).toHaveLength(1); + read = await db.m1.findFirst({ select: { m2: { select: { m4: true } } } }); + expect(read.m2).toHaveLength(1); + }); + it('create simple', async () => { const { withPolicy } = await loadSchema( ` diff --git a/tests/integration/tests/enhancements/with-policy/nested-to-one.test.ts b/tests/integration/tests/enhancements/with-policy/nested-to-one.test.ts index 9d5b9be4b..2e14b6d02 100644 --- a/tests/integration/tests/enhancements/with-policy/nested-to-one.test.ts +++ b/tests/integration/tests/enhancements/with-policy/nested-to-one.test.ts @@ -99,6 +99,59 @@ describe('With Policy:nested to-one', () => { await expect(db.m2.findMany({ include: { m1: true } })).toResolveTruthy(); }); + it('read condition hoisting', async () => { + const { withPolicy } = await loadSchema( + ` + model M1 { + id String @id @default(uuid()) + m2 M2 @relation(fields: [m2Id], references:[id]) + m2Id String @unique + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int + + m1 M1? + + m3 M3 @relation(fields: [m3Id], references:[id]) + m3Id String @unique + + @@allow('create', true) + @@allow('read', value > 0) + } + + model M3 { + id String @id @default(uuid()) + value Int + m2 M2? + + @@allow('create', true) + @@allow('read', value > 1) + } + ` + ); + + const db = withPolicy(); + + await db.m1.create({ + include: { m2: true }, + data: { + id: '1', + m2: { + create: { id: 'm2-1', value: 1, m3: { create: { value: 1 } } }, + }, + }, + }); + + // check m2-m3 filtering + // including m3 causes m1 to be filtered due to hosting + await expect(db.m1.findFirst({ include: { m2: { include: { m3: true } } } })).toResolveNull(); + await expect(db.m1.findFirst({ select: { m2: { select: { m3: true } } } })).toResolveNull(); + }); + it('create and update tests', async () => { const { withPolicy } = await loadSchema( ` diff --git a/tests/integration/tests/regression/issues.test.ts b/tests/integration/tests/regression/issues.test.ts index afb709667..cb46a8f46 100644 --- a/tests/integration/tests/regression/issues.test.ts +++ b/tests/integration/tests/regression/issues.test.ts @@ -354,4 +354,198 @@ describe('GitHub issues regression', () => { }) ).toResolveTruthy(); }); + + it('issue 624', async () => { + const { prisma, withPolicy } = await loadSchema( + ` +model User { + id String @id @default(uuid()) + email String @unique + password String? @password @omit + name String? + orgs Organization[] + posts Post[] + groups Group[] + comments Comment[] + // can be created by anyone, even not logged in + @@allow('create', true) + // can be read by users in the same organization + @@allow('read', orgs?[members?[auth() == this]]) + // full access by oneself + @@allow('all', auth() == this) +} + +model Organization { + id String @id @default(uuid()) + name String + members User[] + post Post[] + groups Group[] + comments Comment[] + + // everyone can create a organization + @@allow('create', true) + // any user in the organization can read the organization + @@allow('read', members?[auth() == this]) +} + +abstract model organizationBaseEntity { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + isDeleted Boolean @default(false) @omit + isPublic Boolean @default(false) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId String + groups Group[] + + // when create, owner must be set to current user, and user must be in the organization + @@allow('create', owner == auth() && org.members?[this == auth()]) + // only the owner can update it and is not allowed to change the owner + @@allow('update', owner == auth() && org.members?[this == auth()] && future().owner == owner) + // allow owner to read + @@allow('read', owner == auth()) + // allow shared group members to read it + @@allow('read', groups?[users?[this == auth()]]) + // allow organization to access if public + @@allow('read', isPublic && org.members?[this == auth()]) + // can not be read if deleted + @@deny('all', isDeleted == true) +} + +model Post extends organizationBaseEntity { + title String + content String + comments Comment[] +} + +model Comment extends organizationBaseEntity { + content String + post Post @relation(fields: [postId], references: [id]) + postId String +} + +model Group { + id String @id @default(uuid()) + name String + users User[] + posts Post[] + comments Comment[] + org Organization @relation(fields: [orgId], references: [id]) + orgId String + + // group is shared by organization + @@allow('all', org.members?[auth() == this]) +} + ` + ); + + const userData = [ + { + id: 'robin@prisma.io', + name: 'Robin', + email: 'robin@prisma.io', + orgs: { + create: [ + { + id: 'prisma', + name: 'prisma', + }, + ], + }, + groups: { + create: [ + { + id: 'community', + name: 'community', + orgId: 'prisma', + }, + ], + }, + posts: { + create: [ + { + id: 'slack', + title: 'Join the Prisma Slack', + content: 'https://slack.prisma.io', + orgId: 'prisma', + comments: { + create: [ + { + id: 'comment-1', + content: 'This is the first comment', + orgId: 'prisma', + ownerId: 'robin@prisma.io', + }, + ], + }, + }, + ], + }, + }, + { + id: 'bryan@prisma.io', + name: 'Bryan', + email: 'bryan@prisma.io', + orgs: { + connect: { + id: 'prisma', + }, + }, + posts: { + create: [ + { + id: 'discord', + title: 'Join the Prisma Discord', + content: 'https://discord.gg/jS3XY7vp46', + orgId: 'prisma', + groups: { + connect: { + id: 'community', + }, + }, + }, + ], + }, + }, + ]; + + for (const u of userData) { + const user = await prisma.user.create({ + data: u, + }); + console.log(`Created user with id: ${user.id}`); + } + + const db = withPolicy({ id: 'robin@prisma.io' }); + await expect( + db.post.findMany({ + where: {}, + select: { + id: true, + content: true, + owner: { + select: { + id: true, + name: true, + }, + }, + comments: { + select: { + id: true, + content: true, + owner: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }) + ).resolves.toHaveLength(2); + }); });