From 7acd7d9b0e50a5147ce262250ac6241a5fee12ad Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 16 Mar 2023 23:25:31 +0800 Subject: [PATCH 1/4] wip --- package.json | 2 +- packages/language/package.json | 2 +- packages/next/package.json | 2 +- packages/plugins/openapi/package.json | 2 +- packages/plugins/openapi/src/generator.ts | 41 +++++++++++++++---- packages/plugins/react/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 4 +- .../src/enhancements/policy/handler.ts | 15 ++++++- .../src/enhancements/policy/policy-utils.ts | 17 ++++++-- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- pnpm-lock.yaml | 4 ++ tests/integration/test-run/package-lock.json | 4 +- 16 files changed, 77 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 7e97c62b5..4473a80dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.74", + "version": "1.0.0-alpha.77", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 439c5b11f..5f3b0d9b7 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.74", + "version": "1.0.0-alpha.77", "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 90a4d9f5a..286b1224f 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.74", + "version": "1.0.0-alpha.77", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 36e4696df..c65a3a1c3 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-alpha.74", + "version": "1.0.0-alpha.77", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/openapi/src/generator.ts b/packages/plugins/openapi/src/generator.ts index edbeacec8..7420f21e5 100644 --- a/packages/plugins/openapi/src/generator.ts +++ b/packages/plugins/openapi/src/generator.ts @@ -27,6 +27,7 @@ export class OpenAPIGenerator { private outputObjectTypes: DMMF.OutputType[] = []; private usedComponents: Set = new Set(); private aggregateOperationSupport: AggregateOperationSupport; + private includedModels: DataModel[]; constructor(private model: Model, private options: PluginOptions, private dmmf: DMMF.Document) {} @@ -39,6 +40,9 @@ export class OpenAPIGenerator { // input types this.inputObjectTypes.push(...this.dmmf.schema.inputObjectTypes.prisma); this.outputObjectTypes.push(...this.dmmf.schema.outputObjectTypes.prisma); + this.includedModels = this.model.declarations.filter( + (d): d is DataModel => isDataModel(d) && !hasAttribute(d, '@@openapi.ignore') + ); // add input object types that are missing from Prisma dmmf addMissingInputObjectTypesForModelArgs(this.inputObjectTypes, this.dmmf.datamodel.models); @@ -59,7 +63,13 @@ export class OpenAPIGenerator { info: { title: this.getOption('title', 'ZenStack Generated API'), version: this.getOption('version', '1.0.0'), + description: this.getOption('description', undefined), + summary: this.getOption('summary', undefined), }, + tags: this.includedModels.map((model) => ({ + name: camelCase(model.name), + description: `${model.name} operations`, + })), components, paths, }; @@ -125,12 +135,10 @@ export class OpenAPIGenerator { private generatePaths(components: OAPI.ComponentsObject): OAPI.PathsObject { let result: OAPI.PathsObject = {}; - const includeModels = this.model.declarations - .filter((d) => isDataModel(d) && !hasAttribute(d, '@@openapi.ignore')) - .map((d) => d.name); + const includeModelNames = this.includedModels.map((d) => d.name); for (const model of this.dmmf.datamodel.models) { - if (includeModels.includes(model.name)) { + if (includeModelNames.includes(model.name)) { const zmodel = this.model.declarations.find( (d) => isDataModel(d) && d.name === model.name ) as DataModel; @@ -465,11 +473,16 @@ export class OpenAPIGenerator { resolvedPath = resolvedPath.substring(1); } + let prefix = this.getOption('prefix', ''); + if (prefix.endsWith('/')) { + prefix = prefix.substring(0, prefix.length - 1); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const def: any = { operationId: `${operation}${model.name}`, description: meta?.description ?? description, - tags: meta?.tags, + tags: meta?.tags || [camelCase(model.name)], summary: meta?.summary, responses: { [successCode !== undefined ? successCode : '200']: { @@ -504,13 +517,17 @@ export class OpenAPIGenerator { name: 'q', in: 'query', required: true, - schema: inputType, + content: { + 'application/json': { + schema: inputType, + }, + }, }, ] satisfies OAPI.ParameterObject[]; } } - result[`/${camelCase(model.name)}/${resolvedPath}`] = { + result[`${prefix}/${camelCase(model.name)}/${resolvedPath}`] = { [resolvedMethod]: def, }; } @@ -546,8 +563,14 @@ export class OpenAPIGenerator { return this.ref(name); } - private getOption(name: string, defaultValue: string) { - return this.options[name] ? (this.options[name] as string) : defaultValue; + private getOption( + name: string, + defaultValue: T + ): T extends string ? string : string | undefined { + const value = this.options[name]; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + return typeof value === 'string' ? value : defaultValue; } private generateComponents() { diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index 0f0b9d242..2d72688b2 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.74", + "version": "1.0.0-alpha.77", "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 98a5db8eb..ac6b1aa5d 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.74", + "version": "1.0.0-alpha.77", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b2ab1432c..d363fd260 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.74", + "version": "1.0.0-alpha.77", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", @@ -28,6 +28,7 @@ "cuid": "^2.1.8", "decimal.js": "^10.4.2", "deepcopy": "^2.1.0", + "pluralize": "^8.0.0", "superjson": "^1.11.0", "tslib": "^2.4.1", "zod": "^3.19.1", @@ -45,6 +46,7 @@ "@types/bcryptjs": "^2.4.2", "@types/jest": "^29.0.3", "@types/node": "^14.18.29", + "@types/pluralize": "^0.0.29", "copyfiles": "^2.4.1", "rimraf": "^3.0.2", "typescript": "^4.9.3" diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index 272cbbb70..9c5ac16b4 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { PrismaClientValidationError } from '@prisma/client/runtime'; +import { CrudFailureReason } from '@zenstackhq/sdk'; import { AuthUser, DbClientContract, PolicyOperationKind } from '../../types'; import { BatchResult, PrismaProxyHandler } from '../proxy'; import { ModelMeta, PolicyDef } from '../types'; @@ -227,7 +228,12 @@ export class PolicyProxyHandler implements Pr await this.modelClient.delete(args); if (!readResult) { - throw this.utils.deniedByPolicy(this.model, 'delete', 'result not readable'); + throw this.utils.deniedByPolicy( + this.model, + 'delete', + 'result is not allowed to be read back', + CrudFailureReason.RESULT_NOT_READABLE + ); } else { return readResult; } @@ -296,7 +302,12 @@ export class PolicyProxyHandler implements Pr const result = await this.utils.readWithCheck(this.model, readArgs); if (result.length === 0) { this.logger.warn(`${action} result cannot be read back`); - throw this.utils.deniedByPolicy(this.model, operation, 'result is not allowed to be read back'); + throw this.utils.deniedByPolicy( + this.model, + operation, + 'result is not allowed to be read back', + CrudFailureReason.RESULT_NOT_READABLE + ); } else if (result.length > 1) { throw this.utils.unknownError('write unexpected resulted in multiple readback entities'); } diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 452ff71e8..f4086ee7f 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -20,6 +20,7 @@ import { NestedWriteVisitor, VisitorContext } from '../nested-write-vistor'; import { ModelMeta, PolicyDef, PolicyFunc } from '../types'; import { enumerate, formatObject, getModelFields } from '../utils'; import { Logger } from './logger'; +import pluralize from 'pluralize'; /** * Access policy enforcement utilities @@ -664,10 +665,10 @@ export class PolicyUtil { } } - deniedByPolicy(model: string, operation: PolicyOperationKind, extra?: string) { + deniedByPolicy(model: string, operation: PolicyOperationKind, extra?: string, reason?: CrudFailureReason) { return new PrismaClientKnownRequestError( `denied by policy: ${model} entities failed '${operation}' check${extra ? ', ' + extra : ''}`, - { clientVersion: getVersion(), code: 'P2004', meta: { reason: CrudFailureReason.RESULT_NOT_READABLE } } + { clientVersion: getVersion(), code: 'P2004', meta: { reason } } ); } @@ -716,7 +717,11 @@ export class PolicyUtil { const entities = await db[model].findMany(guardedQuery); if (entities.length < count) { this.logger.info(`entity ${model} failed policy check for operation ${operation}`); - throw this.deniedByPolicy(model, operation, `${count - entities.length} entities failed policy check`); + throw this.deniedByPolicy( + model, + operation, + `${count - entities.length} ${pluralize('entity', count - entities.length)} failed policy check` + ); } // TODO: push down schema check to the database @@ -731,7 +736,11 @@ export class PolicyUtil { const guardedCount = (await db[model].count(guardedQuery)) as number; if (guardedCount < count) { this.logger.info(`entity ${model} failed policy check for operation ${operation}`); - throw this.deniedByPolicy(model, operation, `${count - guardedCount} entities failed policy check`); + throw this.deniedByPolicy( + model, + operation, + `${count - guardedCount} ${pluralize('entity', count - guardedCount)} failed policy check` + ); } } } diff --git a/packages/schema/package.json b/packages/schema/package.json index 118d4fd50..caa1cc548 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.74", + "version": "1.0.0-alpha.77", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 940f51984..226ee9b2b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.74", + "version": "1.0.0-alpha.77", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 4202ffd16..af5654fce 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-alpha.74", + "version": "1.0.0-alpha.77", "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 deba69c67..dd5f513d7 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.74", + "version": "1.0.0-alpha.77", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0de0fda4..c4c96389c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,7 @@ importers: '@types/bcryptjs': ^2.4.2 '@types/jest': ^29.0.3 '@types/node': ^14.18.29 + '@types/pluralize': ^0.0.29 '@zenstackhq/sdk': workspace:* bcryptjs: ^2.4.3 change-case: ^4.1.2 @@ -188,6 +189,7 @@ importers: cuid: ^2.1.8 decimal.js: ^10.4.2 deepcopy: ^2.1.0 + pluralize: ^8.0.0 rimraf: ^3.0.2 superjson: ^1.11.0 tslib: ^2.4.1 @@ -204,6 +206,7 @@ importers: cuid: 2.1.8 decimal.js: 10.4.2 deepcopy: 2.1.0 + pluralize: 8.0.0 superjson: 1.11.0 tslib: 2.4.1 zod: 3.19.1 @@ -211,6 +214,7 @@ importers: devDependencies: '@types/jest': 29.2.0 '@types/node': 14.18.32 + '@types/pluralize': 0.0.29 copyfiles: 2.4.1 rimraf: 3.0.2 typescript: 4.9.3 diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 4ca1a9d9d..147d8fbd5 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.74", + "version": "1.0.0-alpha.77", "license": "MIT", "dependencies": { "@types/bcryptjs": "^2.4.2", @@ -156,7 +156,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.74", + "version": "1.0.0-alpha.77", "hasInstallScript": true, "license": "MIT", "dependencies": { From e398c0846ff0d764e8d26b6fd7edc0b3dea40766 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 19 Mar 2023 15:26:48 +0800 Subject: [PATCH 2/4] fix: trigger 'update' check for connect/disconnect/connectOrCreate --- .../src/enhancements/nested-write-vistor.ts | 18 ++ .../src/enhancements/policy/policy-utils.ts | 54 ++-- packages/runtime/src/types.ts | 4 +- tests/integration/test-run/package-lock.json | 4 + .../with-policy/connect-disconnect.test.ts | 270 ++++++++++++++++++ .../relation-to-many-filter.test.ts | 1 - 6 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 tests/integration/tests/with-policy/connect-disconnect.test.ts diff --git a/packages/runtime/src/enhancements/nested-write-vistor.ts b/packages/runtime/src/enhancements/nested-write-vistor.ts index 84a1827b8..6436b5970 100644 --- a/packages/runtime/src/enhancements/nested-write-vistor.ts +++ b/packages/runtime/src/enhancements/nested-write-vistor.ts @@ -38,6 +38,10 @@ export type NestedWriterVisitorCallback = { context: VisitorContext ) => Promise; + connect?: (model: string, args: Enumerable, context: VisitorContext) => Promise; + + disconnect?: (model: string, args: Enumerable, context: VisitorContext) => Promise; + update?: (model: string, args: Enumerable<{ where: object; data: any }>, context: VisitorContext) => Promise; updateMany?: ( @@ -142,6 +146,20 @@ export class NestedWriteVisitor { fieldContainers.push(...ensureArray(data).map((d) => d.create)); break; + case 'connect': + context.nestingPath.push({ field, where: data }); + if (this.callback.connect) { + await this.callback.connect(model, data, context); + } + break; + + case 'disconnect': + context.nestingPath.push({ field, where: data }); + if (this.callback.disconnect) { + await this.callback.disconnect(model, data, context); + } + break; + case 'update': context.nestingPath.push({ field, where: data.where }); if (this.callback.update) { diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index f4086ee7f..6b36478f2 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -378,9 +378,18 @@ export class PolicyUtil { // model => { ids, entity value } const updatedModels = new Map; value: any }>>(); + function addUpdatedEntity(model: string, ids: Record, entity: any) { + let modelEntities = updatedModels.get(model); + if (!modelEntities) { + modelEntities = []; + updatedModels.set(model, modelEntities); + } + modelEntities.push({ ids, value: entity }); + } + const idFields = this.getIdFields(model); if (args.select) { - // make sure 'id' field is selected, we need it to + // make sure id fields are selected, we need it to // read back the updated entity for (const idField of idFields) { if (!args.select[idField.name]) { @@ -434,7 +443,7 @@ export class PolicyUtil { }; // args processor for update/upsert - const processUpdate = async (model: string, args: any, context: VisitorContext) => { + const processUpdate = async (model: string, where: any, context: VisitorContext) => { const preGuard = await this.getAuthGuard(model, 'update'); if (preGuard === false) { throw this.deniedByPolicy(model, 'update'); @@ -475,11 +484,10 @@ export class PolicyUtil { const subQuery = await buildReversedQuery(context); await this.checkPolicyForFilter(model, subQuery, 'update', this.db); } else { - // non-nested update, check policies directly - if (!args.where) { - throw this.unknownError(`Missing 'where' in update args`); + if (!where) { + throw this.unknownError(`Missing 'where' parameter`); } - await this.checkPolicyForFilter(model, args.where, 'update', this.db); + await this.checkPolicyForFilter(model, where, 'update', this.db); } } @@ -507,12 +515,6 @@ export class PolicyUtil { // post-update check is needed if there's post-update rule or validation schema if (postGuard !== true || schema) { - let modelEntities = updatedModels.get(model); - if (!modelEntities) { - modelEntities = []; - updatedModels.set(model, modelEntities); - } - // fetch preValue selection (analyzed from the post-update rules) const preValueSelect = await this.getPreValueSelect(model); const filter = await buildReversedQuery(context); @@ -531,10 +533,11 @@ export class PolicyUtil { const query = { where: filter, select }; this.logger.info(`fetching pre-update entities for ${model}: ${formatObject(query)})}`); + const entities = await this.db[model].findMany(query); - entities.forEach((entity) => - modelEntities?.push({ ids: this.getEntityIds(model, entity), value: entity }) - ); + entities.forEach((entity) => { + addUpdatedEntity(model, this.getEntityIds(model, entity), entity); + }); } }; @@ -562,20 +565,35 @@ export class PolicyUtil { } }, - connectOrCreate: async (model, args) => { + connectOrCreate: async (model, args, context) => { for (const oneArgs of enumerate(args)) { if (oneArgs.create) { await processCreate(model, oneArgs.create); } + if (oneArgs.where) { + await processUpdate(model, oneArgs.where, context); + } } }, - update: async (model, args, context) => { + connect: async (model, args, context) => { for (const oneArgs of enumerate(args)) { await processUpdate(model, oneArgs, context); } }, + disconnect: async (model, args, context) => { + for (const oneArgs of enumerate(args)) { + await processUpdate(model, oneArgs, context); + } + }, + + update: async (model, args, context) => { + for (const oneArgs of enumerate(args)) { + await processUpdate(model, oneArgs.where, context); + } + }, + updateMany: async (model, args, context) => { for (const oneArgs of enumerate(args)) { await processUpdateMany(model, oneArgs, context); @@ -589,7 +607,7 @@ export class PolicyUtil { } if (oneArgs.update) { - await processUpdate(model, { where: oneArgs.where, data: oneArgs.update }, context); + await processUpdate(model, { where: oneArgs.where }, context); } } }, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 39b3c4248..b3c02b3cc 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -77,8 +77,10 @@ export const PrismaWriteActions = [ 'update', 'updateMany', 'upsert', + 'connect', + 'disconnect', 'delete', 'deleteMany', ] as const; -export type PrismaWriteActionType = typeof PrismaWriteActions[number]; +export type PrismaWriteActionType = (typeof PrismaWriteActions)[number]; diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 147d8fbd5..dcc7660ec 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -137,6 +137,7 @@ "cuid": "^2.1.8", "decimal.js": "^10.4.2", "deepcopy": "^2.1.0", + "pluralize": "^8.0.0", "superjson": "^1.11.0", "tslib": "^2.4.1", "zod": "^3.19.1", @@ -146,6 +147,7 @@ "@types/bcryptjs": "^2.4.2", "@types/jest": "^29.0.3", "@types/node": "^14.18.29", + "@types/pluralize": "^0.0.29", "copyfiles": "^2.4.1", "rimraf": "^3.0.2", "typescript": "^4.9.3" @@ -345,6 +347,7 @@ "@types/bcryptjs": "^2.4.2", "@types/jest": "^29.0.3", "@types/node": "^14.18.29", + "@types/pluralize": "^0.0.29", "@zenstackhq/sdk": "workspace:*", "bcryptjs": "^2.4.3", "change-case": "^4.1.2", @@ -353,6 +356,7 @@ "cuid": "^2.1.8", "decimal.js": "^10.4.2", "deepcopy": "^2.1.0", + "pluralize": "^8.0.0", "rimraf": "^3.0.2", "superjson": "^1.11.0", "tslib": "^2.4.1", diff --git a/tests/integration/tests/with-policy/connect-disconnect.test.ts b/tests/integration/tests/with-policy/connect-disconnect.test.ts new file mode 100644 index 000000000..613b8ed53 --- /dev/null +++ b/tests/integration/tests/with-policy/connect-disconnect.test.ts @@ -0,0 +1,270 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('With Policy: connect-disconnect', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + const modelToMany = ` + model M1 { + id String @id @default(uuid()) + m2 M2[] + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int + deleted Boolean @default(false) + m1 M1? @relation(fields: [m1Id], references:[id]) + m1Id String? + m3 M3[] + + @@allow('read,create', true) + @@allow('update', !deleted) + } + + model M3 { + id String @id @default(uuid()) + value Int + deleted Boolean @default(false) + m2 M2? @relation(fields: [m2Id], references:[id]) + m2Id String? + + @@allow('read,create', true) + @@allow('update', !deleted) + } + `; + + it('simple to-many', async () => { + const { withPolicy, prisma } = await loadSchema(modelToMany); + + const db = withPolicy(); + + await db.m2.create({ data: { id: 'm2-1', value: 1, deleted: false } }); + await db.m1.create({ + data: { + id: 'm1-1', + m2: { + connect: { id: 'm2-1' }, + }, + }, + }); + await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: true } }); + await expect( + db.m1.update({ + where: { id: 'm1-1' }, + data: { + m2: { + disconnect: { id: 'm2-1' }, + }, + }, + }) + ).toBeRejectedByPolicy(); + await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: false } }); + await db.m1.update({ + where: { id: 'm1-1' }, + data: { + m2: { + disconnect: { id: 'm2-1' }, + }, + }, + }); + + await db.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } }); + await expect( + db.m1.create({ + data: { + m2: { + connect: { id: 'm2-2' }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + // mixed create and connect + await db.m2.create({ data: { id: 'm2-3', value: 1, deleted: false } }); + await db.m1.create({ + data: { + m2: { + connect: { id: 'm2-3' }, + create: { value: 1, deleted: false }, + }, + }, + }); + + await db.m2.create({ data: { id: 'm2-4', value: 1, deleted: true } }); + await expect( + db.m1.create({ + data: { + m2: { + connect: { id: 'm2-4' }, + create: { value: 1, deleted: false }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + // connectOrCreate + await db.m1.create({ + data: { + m2: { + connectOrCreate: { + where: { id: 'm2-5' }, + create: { value: 1 }, + }, + }, + }, + }); + + await db.m2.create({ data: { id: 'm2-6', value: 1, deleted: true } }); + await expect( + db.m1.create({ + data: { + m2: { + connectOrCreate: { + where: { id: 'm2-6' }, + create: { value: 1 }, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + }); + + it('nested to-many', async () => { + const { withPolicy } = await loadSchema(modelToMany); + + const db = withPolicy(); + + await db.m3.create({ data: { id: 'm3-1', value: 1, deleted: false } }); + await expect( + db.m1.create({ + data: { + id: 'm1-1', + m2: { + create: { + value: 1, + m3: { connect: { id: 'm3-1' } }, + }, + }, + }, + }) + ).toResolveTruthy(); + + await db.m3.create({ data: { id: 'm3-2', value: 1, deleted: true } }); + await expect( + db.m1.create({ + data: { + m2: { + create: { + value: 1, + m3: { connect: { id: 'm3-2' } }, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + }); + + const modelToOne = ` + model M1 { + id String @id @default(uuid()) + m2 M2? + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + value Int + deleted Boolean @default(false) + m1 M1? @relation(fields: [m1Id], references:[id]) + m1Id String? @unique + + @@allow('read,create', true) + @@allow('update', !deleted) + } + `; + + it('to-one', async () => { + const { withPolicy, prisma } = await loadSchema(modelToOne); + + const db = withPolicy(); + + await db.m2.create({ data: { id: 'm2-1', value: 1, deleted: false } }); + await db.m1.create({ + data: { + id: 'm1-1', + m2: { + connect: { id: 'm2-1' }, + }, + }, + }); + await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: true } }); + await expect( + db.m1.update({ + where: { id: 'm1-1' }, + data: { + m2: { + disconnect: { id: 'm2-1' }, + }, + }, + }) + ).toBeRejectedByPolicy(); + await prisma.m2.update({ where: { id: 'm2-1' }, data: { deleted: false } }); + await db.m1.update({ + where: { id: 'm1-1' }, + data: { + m2: { + disconnect: true, + }, + }, + }); + + await db.m2.create({ data: { id: 'm2-2', value: 1, deleted: true } }); + await expect( + db.m1.create({ + data: { + m2: { + connect: { id: 'm2-2' }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + // connectOrCreate + await db.m1.create({ + data: { + m2: { + connectOrCreate: { + where: { id: 'm2-3' }, + create: { value: 1 }, + }, + }, + }, + }); + + await db.m2.create({ data: { id: 'm2-4', value: 1, deleted: true } }); + await expect( + db.m1.create({ + data: { + m2: { + connectOrCreate: { + where: { id: 'm2-4' }, + create: { value: 1 }, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + }); +}); diff --git a/tests/integration/tests/with-policy/relation-to-many-filter.test.ts b/tests/integration/tests/with-policy/relation-to-many-filter.test.ts index ae1749237..e66d35761 100644 --- a/tests/integration/tests/with-policy/relation-to-many-filter.test.ts +++ b/tests/integration/tests/with-policy/relation-to-many-filter.test.ts @@ -3,7 +3,6 @@ import path from 'path'; describe('With Policy: relation to-many filter', () => { let origDir: string; - const suite = 'relation-to-many-filter'; beforeAll(async () => { origDir = path.resolve('.'); From 51ba0a2739d37ef73a5ec70e356920b3138d89b2 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 19 Mar 2023 15:50:43 +0800 Subject: [PATCH 3/4] fix tests --- tests/integration/tests/with-policy/deep-nested.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/tests/with-policy/deep-nested.test.ts b/tests/integration/tests/with-policy/deep-nested.test.ts index bf2b9a8e0..842feddbd 100644 --- a/tests/integration/tests/with-policy/deep-nested.test.ts +++ b/tests/integration/tests/with-policy/deep-nested.test.ts @@ -91,7 +91,7 @@ describe('With Policy:deep nested', () => { }, m4: { create: [ - { id: 'm4-1', value: 21 }, + { id: 'm4-1', value: 22 }, { id: 'm4-2', value: 22 }, ], }, @@ -403,7 +403,7 @@ describe('With Policy:deep nested', () => { where: { myId: '1' }, include: { m2: { select: { m4: true } } }, }) - ).toBeRejectedByPolicy(['result not readable']); + ).toBeRejectedByPolicy(['result is not allowed to be read back']); await expect(db.m4.findMany()).resolves.toHaveLength(0); From 0739354d66a0fb57a2768549fbce0cd7dbe57bd7 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 19 Mar 2023 18:40:46 +0800 Subject: [PATCH 4/4] more fixes --- package.json | 2 +- packages/language/package.json | 2 +- packages/next/package.json | 2 +- packages/plugins/openapi/package.json | 2 +- packages/plugins/react/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 2 +- .../src/enhancements/nested-write-vistor.ts | 29 ++++++++------- .../src/enhancements/policy/policy-utils.ts | 28 ++++++++++++--- packages/runtime/src/types.ts | 36 +++++++++++++++++++ packages/schema/package.json | 2 +- .../schema/src/plugins/model-meta/index.ts | 13 ++++--- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- 15 files changed, 96 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 4473a80dc..f077ca392 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.77", + "version": "1.0.0-alpha.78", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 5f3b0d9b7..5d5ffadce 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.77", + "version": "1.0.0-alpha.78", "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 286b1224f..f8d043b4d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.77", + "version": "1.0.0-alpha.78", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index c65a3a1c3..0791d0bef 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-alpha.77", + "version": "1.0.0-alpha.78", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index 2d72688b2..9daf7cf4d 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.77", + "version": "1.0.0-alpha.78", "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 ac6b1aa5d..f5e01c80f 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.77", + "version": "1.0.0-alpha.78", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d363fd260..63424b487 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.77", + "version": "1.0.0-alpha.78", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/enhancements/nested-write-vistor.ts b/packages/runtime/src/enhancements/nested-write-vistor.ts index 6436b5970..23188b7d4 100644 --- a/packages/runtime/src/enhancements/nested-write-vistor.ts +++ b/packages/runtime/src/enhancements/nested-write-vistor.ts @@ -6,6 +6,8 @@ import { resolveField } from './model-meta'; import { ModelMeta } from './types'; import { Enumerable, ensureArray, getModelFields } from './utils'; +type NestingPathItem = { field?: FieldInfo; where: any; unique: boolean }; + /** * Context for visiting */ @@ -23,7 +25,7 @@ export type VisitorContext = { /** * A top-down path of all nested update conditions and corresponding field till now */ - nestingPath: { field?: FieldInfo; where: any }[]; + nestingPath: NestingPathItem[]; }; /** @@ -107,7 +109,7 @@ export class NestedWriteVisitor { data: any, parent: any, field: FieldInfo | undefined, - nestingPath: { field?: FieldInfo; where: any }[] + nestingPath: NestingPathItem[] ): Promise { if (!data) { return; @@ -120,7 +122,7 @@ export class NestedWriteVisitor { // visit payload switch (action) { case 'create': - context.nestingPath.push({ field, where: {} }); + context.nestingPath.push({ field, where: {}, unique: false }); if (this.callback.create) { await this.callback.create(model, data, context); } @@ -130,7 +132,7 @@ export class NestedWriteVisitor { case 'createMany': // skip the 'data' layer so as to keep consistency with 'create' if (data.data) { - context.nestingPath.push({ field, where: {} }); + context.nestingPath.push({ field, where: {}, unique: false }); if (this.callback.create) { await this.callback.create(model, data.data, context); } @@ -139,7 +141,7 @@ export class NestedWriteVisitor { break; case 'connectOrCreate': - context.nestingPath.push({ field, where: data.where }); + context.nestingPath.push({ field, where: data.where, unique: true }); if (this.callback.connectOrCreate) { await this.callback.connectOrCreate(model, data, context); } @@ -147,21 +149,24 @@ export class NestedWriteVisitor { break; case 'connect': - context.nestingPath.push({ field, where: data }); + context.nestingPath.push({ field, where: data, unique: true }); if (this.callback.connect) { await this.callback.connect(model, data, context); } break; case 'disconnect': - context.nestingPath.push({ field, where: data }); + // disconnect has two forms: + // if relation is to-many, the payload is a unique filter object + // if relation is to-one, the payload can only be boolean `true` + context.nestingPath.push({ field, where: data, unique: typeof data === 'object' }); if (this.callback.disconnect) { await this.callback.disconnect(model, data, context); } break; case 'update': - context.nestingPath.push({ field, where: data.where }); + context.nestingPath.push({ field, where: data.where, unique: false }); if (this.callback.update) { await this.callback.update(model, data, context); } @@ -169,7 +174,7 @@ export class NestedWriteVisitor { break; case 'updateMany': - context.nestingPath.push({ field, where: data.where }); + context.nestingPath.push({ field, where: data.where, unique: false }); if (this.callback.updateMany) { await this.callback.updateMany(model, data, context); } @@ -177,7 +182,7 @@ export class NestedWriteVisitor { break; case 'upsert': - context.nestingPath.push({ field, where: data.where }); + context.nestingPath.push({ field, where: data.where, unique: true }); if (this.callback.upsert) { await this.callback.upsert(model, data, context); } @@ -186,14 +191,14 @@ export class NestedWriteVisitor { break; case 'delete': - context.nestingPath.push({ field, where: data.where }); + context.nestingPath.push({ field, where: data.where, unique: false }); if (this.callback.delete) { await this.callback.delete(model, data, context); } break; case 'deleteMany': - context.nestingPath.push({ field, where: data.where }); + context.nestingPath.push({ field, where: data.where, unique: false }); if (this.callback.deleteMany) { await this.callback.deleteMany(model, data, context); } diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 6b36478f2..ac00978a2 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -421,7 +421,7 @@ export class PolicyUtil { let currField: FieldInfo | undefined; for (let i = context.nestingPath.length - 1; i >= 0; i--) { - const { field, where } = context.nestingPath[i]; + const { field, where, unique } = context.nestingPath[i]; if (!result) { // first segment (bottom), just use its where clause @@ -438,6 +438,11 @@ export class PolicyUtil { currQuery = currQuery[currField.backLink]; currField = field; } + + if (unique) { + // hit a unique filter, no need to traverse further up + break; + } } return result; }; @@ -557,6 +562,19 @@ export class PolicyUtil { } }; + // process relation updates: connect, connectOrCreate, and disconnect + const processRelationUpdate = async (model: string, args: any, context: VisitorContext) => { + if (context.field?.backLink) { + // fetch the backlink field of the model being connected + const backLinkField = resolveField(this.modelMeta, model, context.field.backLink); + if (backLinkField.isRelationOwner) { + // the target side of relation owns the relation, + // mark it as updated + await processUpdate(model, args, context); + } + } + }; + // use a visitor to process args before conducting the write action const visitor = new NestedWriteVisitor(this.modelMeta, { create: async (model, args) => { @@ -571,20 +589,20 @@ export class PolicyUtil { await processCreate(model, oneArgs.create); } if (oneArgs.where) { - await processUpdate(model, oneArgs.where, context); + await processRelationUpdate(model, oneArgs.where, context); } } }, connect: async (model, args, context) => { for (const oneArgs of enumerate(args)) { - await processUpdate(model, oneArgs, context); + await processRelationUpdate(model, oneArgs, context); } }, disconnect: async (model, args, context) => { for (const oneArgs of enumerate(args)) { - await processUpdate(model, oneArgs, context); + await processRelationUpdate(model, oneArgs, context); } }, @@ -607,7 +625,7 @@ export class PolicyUtil { } if (oneArgs.update) { - await processUpdate(model, { where: oneArgs.where }, context); + await processUpdate(model, oneArgs.where, context); } } }, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index b3c02b3cc..b8ecb0122 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -56,14 +56,50 @@ export type RuntimeAttribute = { * Runtime information of a data model field */ export type FieldInfo = { + /** + * Field name + */ name: string; + + /** + * Field type name + */ type: string; + + /** + * If the field is an ID field or part of a multi-field ID + */ isId: boolean; + + /** + * If the field type is a data model (or an optional/array of data model) + */ isDataModel: boolean; + + /** + * If the field is an array + */ isArray: boolean; + + /** + * If the field is optional + */ isOptional: boolean; + + /** + * Attributes on the field + */ attributes: RuntimeAttribute[]; + + /** + * If the field is a relation field, the field name of the reverse side of the relation + */ backLink?: string; + + /** + * If the field is the owner side of a relation + */ + isRelationOwner: boolean; }; export type DbClientContract = Record & { diff --git a/packages/schema/package.json b/packages/schema/package.json index caa1cc548..1d85b753c 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.77", + "version": "1.0.0-alpha.78", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/plugins/model-meta/index.ts b/packages/schema/src/plugins/model-meta/index.ts index 43d6a255a..1608baf2e 100644 --- a/packages/schema/src/plugins/model-meta/index.ts +++ b/packages/schema/src/plugins/model-meta/index.ts @@ -8,7 +8,7 @@ import { ReferenceExpr, } from '@zenstackhq/language/ast'; import { RuntimeAttribute } from '@zenstackhq/runtime'; -import { getAttributeArgs, getLiteral, PluginOptions, resolved } from '@zenstackhq/sdk'; +import { getAttributeArgs, getLiteral, hasAttribute, PluginOptions, resolved } from '@zenstackhq/sdk'; import { camelCase } from 'change-case'; import path from 'path'; import { CodeBlockWriter, Project, VariableDeclarationKind } from 'ts-morph'; @@ -67,7 +67,8 @@ function generateModelMetadata(dataModels: DataModel[], writer: CodeBlockWriter) isArray: ${f.type.array}, isOptional: ${f.type.optional}, attributes: ${JSON.stringify(getFieldAttributes(f))}, - backLink: ${backlink ? "'" + backlink + "'" : 'undefined'} + backLink: ${backlink ? "'" + backlink.name + "'" : 'undefined'}, + isRelationOwner: ${isRelationOwner(f)}, },`); } }); @@ -110,10 +111,10 @@ function getBackLink(field: DataModelField) { if (relName) { const otherRelName = getRelationName(otherField); if (relName === otherRelName) { - return otherField.name; + return otherField; } } else { - return otherField.name; + return otherField; } } } @@ -177,3 +178,7 @@ function getUniqueConstraints(model: DataModel) { } return constraints; } + +function isRelationOwner(field: DataModelField) { + return hasAttribute(field, '@relation'); +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 226ee9b2b..54bdf7048 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.77", + "version": "1.0.0-alpha.78", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index af5654fce..87b647919 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-alpha.77", + "version": "1.0.0-alpha.78", "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 dd5f513d7..5af9f6767 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.77", + "version": "1.0.0-alpha.78", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": {