diff --git a/package.json b/package.json index a8af271b3..77186957a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.10.1", + "version": "1.10.2", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 578f2334e..991ec903d 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "1.10.1" +version = "1.10.2" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index fcb014277..95a8cd155 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "1.10.1", + "version": "1.10.2", "displayName": "ZenStack JetBrains IDE Plugin", "description": "ZenStack JetBrains IDE plugin", "homepage": "https://zenstack.dev", diff --git a/packages/language/package.json b/packages/language/package.json index e0a904034..998be4724 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.10.1", + "version": "1.10.2", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index ff15cb51b..b31af2556 100644 --- a/packages/misc/redwood/package.json +++ b/packages/misc/redwood/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/redwood", "displayName": "ZenStack RedwoodJS Integration", - "version": "1.10.1", + "version": "1.10.2", "description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.", "repository": { "type": "git", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 0aff04595..85c46c860 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.10.1", + "version": "1.10.2", "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 813b750f0..9e12e6e35 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.10.1", + "version": "1.10.2", "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 49edb485d..993188e34 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.10.1", + "version": "1.10.2", "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 8dbf8719b..f8df06310 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.10.1", + "version": "1.10.2", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f77b44d13..a789ac665 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.10.1", + "version": "1.10.2", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/cross/nested-write-visitor.ts b/packages/runtime/src/cross/nested-write-visitor.ts index 7d67f6d9b..7c4e8e5e5 100644 --- a/packages/runtime/src/cross/nested-write-visitor.ts +++ b/packages/runtime/src/cross/nested-write-visitor.ts @@ -4,7 +4,7 @@ import type { FieldInfo, ModelMeta } from './model-meta'; import { resolveField } from './model-meta'; import { MaybePromise, PrismaWriteActionType, PrismaWriteActions } from './types'; -import { enumerate, getModelFields } from './utils'; +import { getModelFields } from './utils'; type NestingPathItem = { field?: FieldInfo; model: string; where: any; unique: boolean }; @@ -155,7 +155,7 @@ export class NestedWriteVisitor { // visit payload switch (action) { case 'create': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, {}); let callbackResult: any; if (this.callback.create) { @@ -183,7 +183,7 @@ export class NestedWriteVisitor { break; case 'connectOrCreate': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.connectOrCreate) { @@ -198,7 +198,7 @@ export class NestedWriteVisitor { case 'connect': if (this.callback.connect) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item, true); await this.callback.connect(model, item, newContext); } @@ -210,7 +210,7 @@ export class NestedWriteVisitor { // if relation is to-many, the payload is a unique filter object // if relation is to-one, the payload can only be boolean `true` if (this.callback.disconnect) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item, typeof item === 'object'); await this.callback.disconnect(model, item, newContext); } @@ -225,7 +225,7 @@ export class NestedWriteVisitor { break; case 'update': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.update) { @@ -244,7 +244,7 @@ export class NestedWriteVisitor { break; case 'updateMany': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.updateMany) { @@ -258,7 +258,7 @@ export class NestedWriteVisitor { break; case 'upsert': { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.upsert) { @@ -278,7 +278,7 @@ export class NestedWriteVisitor { case 'delete': { if (this.callback.delete) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, toplevel ? item.where : item); await this.callback.delete(model, item, newContext); } @@ -288,7 +288,7 @@ export class NestedWriteVisitor { case 'deleteMany': if (this.callback.deleteMany) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, toplevel ? item.where : item); await this.callback.deleteMany(model, item, newContext); } @@ -336,4 +336,16 @@ export class NestedWriteVisitor { } } } + + // enumerate a (possible) array in reverse order, so that the enumeration + // callback can safely delete the current item + private *enumerateReverse(data: any) { + if (Array.isArray(data)) { + for (let i = data.length - 1; i >= 0; i--) { + yield data[i]; + } + } else { + yield data; + } + } } diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index e11379cdf..808763ae8 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -343,17 +343,7 @@ export class PolicyProxyHandler implements Pr } } - if (context.parent.connect) { - // if the payload parent already has a "connect" clause, merge it - if (Array.isArray(context.parent.connect)) { - context.parent.connect.push(args.where); - } else { - context.parent.connect = [context.parent.connect, args.where]; - } - } else { - // otherwise, create a new "connect" clause - context.parent.connect = args.where; - } + this.mergeToParent(context.parent, 'connect', args.where); // record the key of connected entities so we can avoid validating them later connectedEntities.add(getEntityKey(model, existing)); } else { @@ -361,11 +351,11 @@ export class PolicyProxyHandler implements Pr pushIdFields(model, context); // create a new "create" clause at the parent level - context.parent.create = args.create; + this.mergeToParent(context.parent, 'create', args.create); } // remove the connectOrCreate clause - delete context.parent['connectOrCreate']; + this.removeFromParent(context.parent, 'connectOrCreate', args); // return false to prevent visiting the nested payload return false; @@ -895,7 +885,7 @@ export class PolicyProxyHandler implements Pr await _create(model, args, context); // remove it from the update payload - delete context.parent.create; + this.removeFromParent(context.parent, 'create', args); // don't visit payload return false; @@ -928,14 +918,15 @@ export class PolicyProxyHandler implements Pr await _registerPostUpdateCheck(model, uniqueFilter); // convert upsert to update - context.parent.update = { + const convertedUpdate = { where: args.where, data: this.validateUpdateInputSchema(model, args.update), }; - delete context.parent.upsert; + this.mergeToParent(context.parent, 'update', convertedUpdate); + this.removeFromParent(context.parent, 'upsert', args); // continue visiting the new payload - return context.parent.update; + return convertedUpdate; } else { // create case @@ -943,7 +934,7 @@ export class PolicyProxyHandler implements Pr await _create(model, args.create, context); // remove it from the update payload - delete context.parent.upsert; + this.removeFromParent(context.parent, 'upsert', args); // don't visit payload return false; @@ -1390,5 +1381,31 @@ export class PolicyProxyHandler implements Pr return requireField(this.modelMeta, fieldInfo.type, fieldInfo.backLink); } + private mergeToParent(parent: any, key: string, value: any) { + if (parent[key]) { + if (Array.isArray(parent[key])) { + parent[key].push(value); + } else { + parent[key] = [parent[key], value]; + } + } else { + parent[key] = value; + } + } + + private removeFromParent(parent: any, key: string, data: any) { + if (parent[key] === data) { + delete parent[key]; + } else if (Array.isArray(parent[key])) { + const idx = parent[key].indexOf(data); + if (idx >= 0) { + parent[key].splice(idx, 1); + if (parent[key].length === 0) { + delete parent[key]; + } + } + } + } + //#endregion } diff --git a/packages/schema/package.json b/packages/schema/package.json index cf6598db3..200ad7d03 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "Build scalable web apps with minimum code by defining authorization and validation rules inside the data schema that closer to the database", - "version": "1.10.1", + "version": "1.10.2", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 71d0e1144..392afa394 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.10.1", + "version": "1.10.2", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 76a711e8c..3e3e95711 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.10.1", + "version": "1.10.2", "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 ae102ca3d..b5a896b31 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.10.1", + "version": "1.10.2", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/tests/integration/tests/regression/issue-1078.test.ts b/tests/integration/tests/regression/issue-1078.test.ts index 4f8ad6527..3c0fc7024 100644 --- a/tests/integration/tests/regression/issue-1078.test.ts +++ b/tests/integration/tests/regression/issue-1078.test.ts @@ -2,7 +2,7 @@ import { loadSchema } from '@zenstackhq/testtools'; describe('issue 1078', () => { it('regression', async () => { - const { prisma, enhance } = await loadSchema( + const { enhance } = await loadSchema( ` model Counter { id String @id @@ -12,21 +12,25 @@ describe('issue 1078', () => { @@validate(value >= 0) @@allow('all', true) - } + } ` ); const db = enhance(); - const counter = await db.counter.create({ - data: { id: '1', name: 'It should create', value: 1 }, - }); + await expect( + db.counter.create({ + data: { id: '1', name: 'It should create', value: 1 }, + }) + ).toResolveTruthy(); //! This query fails validation - const updated = await db.counter.update({ - where: { id: '1' }, - data: { name: 'It should update' }, - }); + await expect( + db.counter.update({ + where: { id: '1' }, + data: { name: 'It should update' }, + }) + ).toResolveTruthy(); }); it('read', async () => { @@ -37,8 +41,7 @@ describe('issue 1078', () => { title String @allow('read', true, true) content String } - `, - { logPrismaQuery: true } + ` ); const db = enhance(); diff --git a/tests/integration/tests/regression/issue-1080.test.ts b/tests/integration/tests/regression/issue-1080.test.ts new file mode 100644 index 000000000..17ce998c2 --- /dev/null +++ b/tests/integration/tests/regression/issue-1080.test.ts @@ -0,0 +1,133 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1080', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model Project { + id String @id @unique @default(uuid()) + Fields Field[] + + @@allow('all', true) + } + + model Field { + id String @id @unique @default(uuid()) + name String + Project Project @relation(fields: [projectId], references: [id]) + projectId String + + @@allow('all', true) + } + `, + { logPrismaQuery: true } + ); + + const db = enhance(); + + const project = await db.project.create({ + include: { Fields: true }, + data: { + Fields: { + create: [{ name: 'first' }, { name: 'second' }], + }, + }, + }); + + let updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: [ + { + where: { id: project.Fields[0].id }, + create: { name: 'first1' }, + update: { name: 'first1' }, + }, + { + where: { id: project.Fields[1].id }, + create: { name: 'second1' }, + update: { name: 'second1' }, + }, + ], + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first1' }), + expect.objectContaining({ name: 'second1' }), + ]), + }); + + updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: { + where: { id: project.Fields[0].id }, + create: { name: 'first2' }, + update: { name: 'first2' }, + }, + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first2' }), + expect.objectContaining({ name: 'second1' }), + ]), + }); + + updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: { + where: { id: project.Fields[0].id }, + create: { name: 'first3' }, + update: { name: 'first3' }, + }, + update: { + where: { id: project.Fields[1].id }, + data: { name: 'second3' }, + }, + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first3' }), + expect.objectContaining({ name: 'second3' }), + ]), + }); + + updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: { + where: { id: 'non-exist' }, + create: { name: 'third1' }, + update: { name: 'third1' }, + }, + update: { + where: { id: project.Fields[1].id }, + data: { name: 'second4' }, + }, + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first3' }), + expect.objectContaining({ name: 'second4' }), + expect.objectContaining({ name: 'third1' }), + ]), + }); + }); +});