From 24760be0f6286089c58df893ec1ae9c192ba17e2 Mon Sep 17 00:00:00 2001 From: Yiming Date: Wed, 5 Jul 2023 09:56:15 +0800 Subject: [PATCH 1/2] fix: invalid query sent to Prisma when doing nested update with multi-id (#553) --- .../src/enhancements/nested-write-vistor.ts | 22 ++-- .../src/enhancements/policy/policy-utils.ts | 24 +++- .../tests/regression/issues.test.ts | 109 ++++++++++++++++ .../tests/with-policy/multi-id-fields.test.ts | 117 ++++++++++++++++++ 4 files changed, 258 insertions(+), 14 deletions(-) diff --git a/packages/runtime/src/enhancements/nested-write-vistor.ts b/packages/runtime/src/enhancements/nested-write-vistor.ts index 3143cd4b3..5dd13c0a3 100644 --- a/packages/runtime/src/enhancements/nested-write-vistor.ts +++ b/packages/runtime/src/enhancements/nested-write-vistor.ts @@ -6,7 +6,7 @@ import { resolveField } from './model-meta'; import { ModelMeta } from './types'; import { enumerate, getModelFields } from './utils'; -type NestingPathItem = { field?: FieldInfo; where: any; unique: boolean }; +type NestingPathItem = { field?: FieldInfo; model: string; where: any; unique: boolean }; /** * Context for visiting @@ -113,7 +113,7 @@ export class NestedWriteVisitor { // visit payload switch (action) { case 'create': - context.nestingPath.push({ field, where: {}, unique: false }); + context.nestingPath.push({ field, model, where: {}, unique: false }); for (const item of enumerate(data)) { if (this.callback.create) { await this.callback.create(model, item, context); @@ -125,7 +125,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: {}, unique: false }); + context.nestingPath.push({ field, model, where: {}, unique: false }); for (const item of enumerate(data.data)) { if (this.callback.create) { await this.callback.create(model, item, context); @@ -136,7 +136,7 @@ export class NestedWriteVisitor { break; case 'connectOrCreate': - context.nestingPath.push({ field, where: data.where, unique: true }); + context.nestingPath.push({ field, model, where: data.where, unique: true }); for (const item of enumerate(data)) { if (this.callback.connectOrCreate) { await this.callback.connectOrCreate(model, item, context); @@ -150,7 +150,7 @@ export class NestedWriteVisitor { for (const item of enumerate(data)) { const newContext = { ...context, - nestingPath: [...context.nestingPath, { field, where: item, unique: true }], + nestingPath: [...context.nestingPath, { field, model, where: item, unique: true }], }; await this.callback.connect(model, item, newContext); } @@ -167,7 +167,7 @@ export class NestedWriteVisitor { ...context, nestingPath: [ ...context.nestingPath, - { field, where: item, unique: typeof item === 'object' }, + { field, model, where: item, unique: typeof item === 'object' }, ], }; await this.callback.disconnect(model, item, newContext); @@ -176,7 +176,7 @@ export class NestedWriteVisitor { break; case 'update': - context.nestingPath.push({ field, where: data.where, unique: false }); + context.nestingPath.push({ field, model, where: data.where, unique: false }); for (const item of enumerate(data)) { if (this.callback.update) { await this.callback.update(model, item, context); @@ -187,7 +187,7 @@ export class NestedWriteVisitor { break; case 'updateMany': - context.nestingPath.push({ field, where: data.where, unique: false }); + context.nestingPath.push({ field, model, where: data.where, unique: false }); for (const item of enumerate(data)) { if (this.callback.updateMany) { await this.callback.updateMany(model, item, context); @@ -197,7 +197,7 @@ export class NestedWriteVisitor { break; case 'upsert': { - context.nestingPath.push({ field, where: data.where, unique: true }); + context.nestingPath.push({ field, model, where: data.where, unique: true }); for (const item of enumerate(data)) { if (this.callback.upsert) { await this.callback.upsert(model, item, context); @@ -210,7 +210,7 @@ export class NestedWriteVisitor { case 'delete': { if (this.callback.delete) { - context.nestingPath.push({ field, where: data.where, unique: false }); + context.nestingPath.push({ field, model, where: data.where, unique: false }); for (const item of enumerate(data)) { await this.callback.delete(model, item, context); } @@ -220,7 +220,7 @@ export class NestedWriteVisitor { case 'deleteMany': if (this.callback.deleteMany) { - context.nestingPath.push({ field, where: data.where, unique: false }); + context.nestingPath.push({ field, model, where: data.where, unique: false }); for (const item of enumerate(data)) { await this.callback.deleteMany(model, item, context); } diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index eccdbb94e..08d8034ae 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -443,11 +443,18 @@ export class PolicyUtil { let currField: FieldInfo | undefined; for (let i = context.nestingPath.length - 1; i >= 0; i--) { - const { field, where, unique } = context.nestingPath[i]; + const { field, model, where, unique } = context.nestingPath[i]; + + // never modify the original where because it's shared in the structure + const visitWhere = { ...where }; + if (model && where) { + // make sure composite unique condition is flattened + await this.flattenGeneratedUniqueField(model, visitWhere); + } if (!result) { // first segment (bottom), just use its where clause - result = currQuery = { ...where }; + result = currQuery = { ...visitWhere }; currField = field; } else { if (!currField) { @@ -456,7 +463,13 @@ export class PolicyUtil { if (!currField.backLink) { throw this.unknownError(`field ${currField.type}.${currField.name} doesn't have a backLink`); } - currQuery[currField.backLink] = { ...where }; + const backLinkField = this.getModelField(currField.type, currField.backLink); + if (backLinkField?.isArray) { + // many-side of relationship, wrap with "some" query + currQuery[currField.backLink] = { some: { ...visitWhere } }; + } else { + currQuery[currField.backLink] = { ...visitWhere }; + } currQuery = currQuery[currField.backLink]; currField = field; } @@ -707,6 +720,11 @@ export class PolicyUtil { } } + private getModelField(model: string, backlinkField: string) { + model = lowerCaseFirst(model); + return this.modelMeta.fields[model]?.[backlinkField]; + } + private transaction(db: DbClientContract, action: (tx: Record) => Promise) { if (db.__zenstack_tx) { // already in transaction, don't nest diff --git a/tests/integration/tests/regression/issues.test.ts b/tests/integration/tests/regression/issues.test.ts index afc5f0324..88551a62d 100644 --- a/tests/integration/tests/regression/issues.test.ts +++ b/tests/integration/tests/regression/issues.test.ts @@ -191,4 +191,113 @@ describe('GitHub issues regression', () => { ` ); }); + + it('issue 552', async () => { + const { withPolicy, prisma } = await loadSchema( + ` + model Tenant { + id Int @id @default(autoincrement()) + name String + + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + users UserTenant[] + + @@map("tenants") + + + @@allow('all', auth().is_super_admin == true) + @@allow('read', users?[user == auth() && status == 'ACTIVE' ]) + @@allow('all', users?[user == auth() && status == 'ACTIVE']) + } + + model User { + id Int @id @default(autoincrement()) + name String + is_super_admin Boolean @default(false) @omit + + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + associated_tenants UserTenant[] + + @@map("users") + + @@allow('read', auth().id == id) + @@allow('all', auth().is_super_admin == true ) + @@allow('read', associated_tenants?[tenant.users?[user == auth() && status == 'ACTIVE']]) + @@allow('all', associated_tenants?[tenant.users?[user == auth() && status == 'ACTIVE']] ) + @@allow('create', associated_tenants?[tenant.users?[user == auth() && status == 'ACTIVE']] ) + @@allow('update', associated_tenants?[tenant.users?[user == auth() && status == 'ACTIVE']] ) + } + + model UserTenant { + user_id Int + user User @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + tenant_id Int + tenant Tenant @relation(fields: [tenant_id], references: [id], onDelete: Cascade, onUpdate: Cascade) + + status String @default('INACTIVE') + + @@map("user_tenants") + + @@id([user_id, tenant_id]) + + @@index([user_id]) + @@index([tenant_id]) + @@index([user_id, tenant_id]) + + @@allow('all', auth().is_super_admin == true ) + @@allow('read', tenant.users?[user == auth() && status == 'ACTIVE' ]) + @@allow('all', tenant.users?[user == auth() && status == 'ACTIVE']) + @@allow('update', tenant.users?[user == auth() && status == 'ACTIVE']) + @@allow('delete', tenant.users?[user == auth() && status == 'ACTIVE']) + @@allow('create', tenant.users?[user == auth() && status == 'ACTIVE']) + } + ` + ); + + await prisma.user.deleteMany(); + await prisma.tenant.deleteMany(); + + await prisma.tenant.create({ + data: { + id: 1, + name: 'tenant 1', + }, + }); + + await prisma.user.create({ + data: { + id: 1, + name: 'user 1', + }, + }); + + await prisma.userTenant.create({ + data: { + user_id: 1, + tenant_id: 1, + }, + }); + + const db = withPolicy({ id: 1, is_super_admin: true }); + await db.userTenant.update({ + where: { + user_id_tenant_id: { + user_id: 1, + tenant_id: 1, + }, + }, + data: { + user: { + update: { + name: 'user 1 updated', + }, + }, + }, + }); + }); }); diff --git a/tests/integration/tests/with-policy/multi-id-fields.test.ts b/tests/integration/tests/with-policy/multi-id-fields.test.ts index 156b9e2ac..5d9a37eaf 100644 --- a/tests/integration/tests/with-policy/multi-id-fields.test.ts +++ b/tests/integration/tests/with-policy/multi-id-fields.test.ts @@ -153,4 +153,121 @@ describe('With Policy: multiple id fields', () => { ).toBeRejectedByPolicy(); await expect(db.q.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } })).toResolveTruthy(); }); + + it('multi-id to-one nested write', async () => { + const { withPolicy } = await loadSchema( + ` + model A { + x Int + y Int + v Int + b B @relation(fields: [bId], references: [id]) + bId Int @unique + + @@id([x, y]) + @@allow('all', v > 0) + } + + model B { + id Int @id + v Int + a A? + + @@allow('all', v > 0) + } + ` + ); + const db = withPolicy(); + await expect( + db.b.create({ + data: { + id: 1, + v: 1, + a: { + create: { + x: 1, + y: 2, + v: 3, + }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.a.update({ + where: { x_y: { x: 1, y: 2 } }, + data: { b: { update: { v: 5 } } }, + }) + ).toResolveTruthy(); + + expect(await db.b.findUnique({ where: { id: 1 } })).toEqual(expect.objectContaining({ v: 5 })); + }); + + it('multi-id to-many nested write', async () => { + const { withPolicy } = await loadSchema( + ` + model A { + x Int + y Int + v Int + b B @relation(fields: [bId], references: [id]) + bId Int @unique + + @@id([x, y]) + @@allow('all', v > 0) + } + + model B { + id Int @id + v Int + a A[] + c C? + + @@allow('all', v > 0) + } + + model C { + id Int @id + v Int + b B @relation(fields: [bId], references: [id]) + bId Int @unique + + @@allow('all', v > 0) + } + ` + ); + const db = withPolicy(); + await expect( + db.b.create({ + data: { + id: 1, + v: 1, + a: { + create: { + x: 1, + y: 2, + v: 2, + }, + }, + c: { + create: { + id: 1, + v: 3, + }, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + db.a.update({ + where: { x_y: { x: 1, y: 2 } }, + data: { b: { update: { v: 5, c: { update: { v: 6 } } } } }, + }) + ).toResolveTruthy(); + + expect(await db.b.findUnique({ where: { id: 1 } })).toEqual(expect.objectContaining({ v: 5 })); + expect(await db.c.findUnique({ where: { id: 1 } })).toEqual(expect.objectContaining({ v: 6 })); + }); }); From 4305bf96ac14fc6fa7b330f47497a7d54e2586a5 Mon Sep 17 00:00:00 2001 From: Yiming Date: Wed, 5 Jul 2023 09:57:40 +0800 Subject: [PATCH 2/2] bump version (#554) --- package.json | 2 +- packages/language/package.json | 2 +- packages/plugins/openapi/package.json | 2 +- packages/plugins/swr/package.json | 2 +- packages/plugins/tanstack-query/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/server/package.json | 2 +- packages/testtools/package.json | 2 +- tests/integration/test-run/package-lock.json | 4 ++-- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index ba5e02efa..6b003ba61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 1f1d217b0..af19e373a 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "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 4791eb354..533da91e4 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.4", + "version": "1.0.0-beta.5", "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 8d95d6831..c0da1601a 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.4", + "version": "1.0.0-beta.5", "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 9cf4075ab..064c65067 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.4", + "version": "1.0.0-beta.5", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index eedaca491..013fe3afb 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.4", + "version": "1.0.0-beta.5", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 4b07fe118..b8f51fc1f 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.4", + "version": "1.0.0-beta.5", "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 4b56e0ef8..14ff19a70 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.4", + "version": "1.0.0-beta.5", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5074646c7..1e557b51a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index fd71504f8..a5782b3e8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "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 90dc5302c..cd5099824 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "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 453ba7693..2a18daa4e 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-beta.4", + "version": "1.0.0-beta.5", "license": "MIT", "dependencies": { "@paralleldrive/cuid2": "^2.2.0", @@ -160,7 +160,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "hasInstallScript": true, "license": "MIT", "dependencies": {