diff --git a/package.json b/package.json index bb6220a60..af774f8c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.12.3", + "version": "1.12.4", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index b57e88259..474b361fc 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.12.3" +version = "1.12.4" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index 4770c785a..c0bf7dcf1 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "1.12.3", + "version": "1.12.4", "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 9fe87f55b..3eb95b144 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.12.3", + "version": "1.12.4", "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 dc1c5eb81..c5eb9d74d 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.12.3", + "version": "1.12.4", "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 72a3d5109..7c03491e1 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.12.3", + "version": "1.12.4", "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 68ebc3d0d..7902164b1 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.12.3", + "version": "1.12.4", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/tests/test-model-meta.ts b/packages/plugins/swr/tests/test-model-meta.ts index 41731ad18..9eb4c0e2f 100644 --- a/packages/plugins/swr/tests/test-model-meta.ts +++ b/packages/plugins/swr/tests/test-model-meta.ts @@ -43,7 +43,10 @@ export const modelMeta: ModelMeta = { ownerId: { ...fieldDefaults, type: 'User', name: 'owner', isForeignKey: true }, }, }, - uniqueConstraints: {}, + uniqueConstraints: { + user: { id: { name: 'id', fields: ['id'] } }, + post: { id: { name: 'id', fields: ['id'] } }, + }, deleteCascade: { user: ['Post'], }, diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 13ed04b1e..25a5a94b7 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.12.3", + "version": "1.12.4", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/tanstack-query/tests/test-model-meta.ts b/packages/plugins/tanstack-query/tests/test-model-meta.ts index 41731ad18..9eb4c0e2f 100644 --- a/packages/plugins/tanstack-query/tests/test-model-meta.ts +++ b/packages/plugins/tanstack-query/tests/test-model-meta.ts @@ -43,7 +43,10 @@ export const modelMeta: ModelMeta = { ownerId: { ...fieldDefaults, type: 'User', name: 'owner', isForeignKey: true }, }, }, - uniqueConstraints: {}, + uniqueConstraints: { + user: { id: { name: 'id', fields: ['id'] } }, + post: { id: { name: 'id', fields: ['id'] } }, + }, deleteCascade: { user: ['Post'], }, diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index f8e7cd354..880ac1066 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.12.3", + "version": "1.12.4", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c351314d9..893e1e5e0 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.12.3", + "version": "1.12.4", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/cross/utils.ts b/packages/runtime/src/cross/utils.ts index e4237dbc7..08cb29a7e 100644 --- a/packages/runtime/src/cross/utils.ts +++ b/packages/runtime/src/cross/utils.ts @@ -1,5 +1,5 @@ import { lowerCaseFirst } from 'lower-case-first'; -import { ModelMeta } from '.'; +import { ModelMeta, requireField } from '.'; /** * Gets field names in a data model entity, filtering out internal fields. @@ -47,17 +47,15 @@ export function zip(x: Enumerable, y: Enumerable): Array<[T1, T2 } export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound = false) { - let fields = modelMeta.fields[lowerCaseFirst(model)]; - if (!fields) { + const uniqueConstraints = modelMeta.uniqueConstraints[lowerCaseFirst(model)] ?? {}; + + const entries = Object.values(uniqueConstraints); + if (entries.length === 0) { if (throwIfNotFound) { - throw new Error(`Unable to load fields for ${model}`); - } else { - fields = {}; + throw new Error(`Model ${model} does not have any id field`); } + return []; } - const result = Object.values(fields).filter((f) => f.isId); - if (result.length === 0 && throwIfNotFound) { - throw new Error(`model ${model} does not have an id field`); - } - return result; + + return entries[0].fields.map((f) => requireField(modelMeta, model, f)); } diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index 6b7e67bea..34c74f9dd 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -420,7 +420,7 @@ export class PolicyProxyHandler implements Pr }); // return only the ids of the top-level entity - const ids = this.utils.getEntityIds(this.model, result); + const ids = this.utils.getEntityIds(model, result); return { result: ids, postWriteChecks: [...postCreateChecks.values()] }; } @@ -792,8 +792,10 @@ export class PolicyProxyHandler implements Pr } // proceed with the create and collect post-create checks - const { postWriteChecks: checks } = await this.doCreate(model, { data: createData }, db); + const { postWriteChecks: checks, result } = await this.doCreate(model, { data: createData }, db); postWriteChecks.push(...checks); + + return result; }; const _createMany = async ( @@ -881,18 +883,10 @@ export class PolicyProxyHandler implements Pr // check pre-update guard await this.utils.checkPolicyForUnique(model, uniqueFilter, 'update', db, args); - // handles the case where id fields are updated - const postUpdateIds = this.utils.clone(existing); - for (const key of Object.keys(existing)) { - const updateValue = (args as any).data ? (args as any).data[key] : (args as any)[key]; - if ( - typeof updateValue === 'string' || - typeof updateValue === 'number' || - typeof updateValue === 'bigint' - ) { - postUpdateIds[key] = updateValue; - } - } + // handle the case where id fields are updated + const _args: any = args; + const updatePayload = _args.data && typeof _args.data === 'object' ? _args.data : _args; + const postUpdateIds = this.calculatePostUpdateIds(model, existing, updatePayload); // register post-update check await _registerPostUpdateCheck(model, existing, postUpdateIds); @@ -984,10 +978,13 @@ export class PolicyProxyHandler implements Pr // update case // check pre-update guard - await this.utils.checkPolicyForUnique(model, uniqueFilter, 'update', db, args); + await this.utils.checkPolicyForUnique(model, existing, 'update', db, args); + + // handle the case where id fields are updated + const postUpdateIds = this.calculatePostUpdateIds(model, existing, args.update); // register post-update check - await _registerPostUpdateCheck(model, uniqueFilter, uniqueFilter); + await _registerPostUpdateCheck(model, existing, postUpdateIds); // convert upsert to update const convertedUpdate = { @@ -1021,9 +1018,22 @@ export class PolicyProxyHandler implements Pr if (existing) { // connect await _connectDisconnect(model, args.where, context); + return true; } else { // create - await _create(model, args.create, context); + const created = await _create(model, args.create, context); + + const upperContext = context.nestingPath[context.nestingPath.length - 2]; + if (upperContext?.where && context.field) { + // check if the where clause of the upper context references the id + // of the connected entity, if so, we need to update it + this.overrideForeignKeyFields(upperContext.model, upperContext.where, context.field, created); + } + + // remove the payload from the parent + this.removeFromParent(context.parent, 'connectOrCreate', args); + + return false; } }, @@ -1093,6 +1103,52 @@ export class PolicyProxyHandler implements Pr return { result, postWriteChecks }; } + // calculate id fields used for post-update check given an update payload + private calculatePostUpdateIds(_model: string, currentIds: any, updatePayload: any) { + const result = this.utils.clone(currentIds); + for (const key of Object.keys(currentIds)) { + const updateValue = updatePayload[key]; + if (typeof updateValue === 'string' || typeof updateValue === 'number' || typeof updateValue === 'bigint') { + result[key] = updateValue; + } + } + return result; + } + + // updates foreign key fields inside `payload` based on relation id fields in `newIds` + private overrideForeignKeyFields( + model: string, + payload: any, + relation: FieldInfo, + newIds: Record + ) { + if (!relation.foreignKeyMapping || Object.keys(relation.foreignKeyMapping).length === 0) { + return; + } + + // override foreign key values + for (const [id, fk] of Object.entries(relation.foreignKeyMapping)) { + if (payload[fk] !== undefined && newIds[id] !== undefined) { + payload[fk] = newIds[id]; + } + } + + // deal with compound id fields + const uniqueConstraints = this.utils.getUniqueConstraints(model); + for (const [name, constraint] of Object.entries(uniqueConstraints)) { + if (constraint.fields.length > 1) { + const target = payload[name]; + if (target) { + for (const [id, fk] of Object.entries(relation.foreignKeyMapping)) { + if (target[fk] !== undefined && newIds[id] !== undefined) { + target[fk] = newIds[id]; + } + } + } + } + } + } + // Validates the given update payload against Zod schema if any private validateUpdateInputSchema(model: string, data: any) { const schema = this.utils.getZodSchema(model, 'update'); @@ -1224,11 +1280,18 @@ export class PolicyProxyHandler implements Pr const { result, error } = await this.transaction(async (tx) => { const { where, create, update, ...rest } = args; - const existing = await this.utils.checkExistence(tx, this.model, args.where); + const existing = await this.utils.checkExistence(tx, this.model, where); if (existing) { // update case - const { result, postWriteChecks } = await this.doUpdate({ where, data: update, ...rest }, tx); + const { result, postWriteChecks } = await this.doUpdate( + { + where: this.utils.composeCompoundUniqueField(this.model, existing), + data: update, + ...rest, + }, + tx + ); await this.runPostWriteChecks(postWriteChecks, tx); return this.utils.readBack(tx, this.model, 'update', args, result); } else { diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 5a04c0430..4ff3044b8 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -569,6 +569,27 @@ export class PolicyUtil { } } + composeCompoundUniqueField(model: string, fieldData: any) { + const uniqueConstraints = this.modelMeta.uniqueConstraints?.[lowerCaseFirst(model)]; + if (!uniqueConstraints) { + return fieldData; + } + + // e.g.: { a: '1', b: '1' } => { a_b: { a: '1', b: '1' } } + const result: any = this.clone(fieldData); + for (const [name, constraint] of Object.entries(uniqueConstraints)) { + if (constraint.fields.length > 1 && constraint.fields.every((f) => fieldData[f] !== undefined)) { + // multi-field unique constraint, compose it + result[name] = constraint.fields.reduce( + (prev, field) => ({ ...prev, [field]: fieldData[field] }), + {} + ); + constraint.fields.forEach((f) => delete result[f]); + } + } + return result; + } + /** * Gets unique constraints for the given model. */ @@ -642,6 +663,15 @@ export class PolicyUtil { // preserve the original structure currQuery[currField.backLink] = { ...visitWhere }; } + + if (forMutationPayload && currQuery[currField.backLink]) { + // reconstruct compound unique field + currQuery[currField.backLink] = this.composeCompoundUniqueField( + backLinkField.type, + currQuery[currField.backLink] + ); + } + currQuery = currQuery[currField.backLink]; } currField = field; diff --git a/packages/schema/package.json b/packages/schema/package.json index f2e62b4bc..a7f201540 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.12.3", + "version": "1.12.4", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 87e9bffb5..51716a4a6 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.12.3", + "version": "1.12.4", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/sdk/src/model-meta-generator.ts b/packages/sdk/src/model-meta-generator.ts index 15e3abe40..bb7cfd4b8 100644 --- a/packages/sdk/src/model-meta-generator.ts +++ b/packages/sdk/src/model-meta-generator.ts @@ -1,6 +1,7 @@ import { ArrayExpr, DataModel, + DataModelAttribute, DataModelField, isArrayExpr, isBooleanLiteral, @@ -239,10 +240,7 @@ function getFieldAttributes(field: DataModelField): RuntimeAttribute[] { function getUniqueConstraints(model: DataModel) { const constraints: Array<{ name: string; fields: string[] }> = []; - // model-level constraints - for (const attr of model.attributes.filter( - (attr) => attr.decl.ref?.name === '@@unique' || attr.decl.ref?.name === '@@id' - )) { + const extractConstraint = (attr: DataModelAttribute) => { const argsMap = getAttributeArgs(attr); if (argsMap.fields) { const fieldNames = (argsMap.fields as ArrayExpr).items.map( @@ -253,14 +251,45 @@ function getUniqueConstraints(model: DataModel) { // default constraint name is fields concatenated with underscores constraintName = fieldNames.join('_'); } - constraints.push({ name: constraintName, fields: fieldNames }); + return { name: constraintName, fields: fieldNames }; + } else { + return undefined; + } + }; + + const addConstraint = (constraint: { name: string; fields: string[] }) => { + if (!constraints.some((c) => c.name === constraint.name)) { + constraints.push(constraint); + } + }; + + // field-level @id first + for (const field of model.fields) { + if (hasAttribute(field, '@id')) { + addConstraint({ name: field.name, fields: [field.name] }); } } - // field-level constraints + // then model-level @@id + for (const attr of model.attributes.filter((attr) => attr.decl.ref?.name === '@@id')) { + const constraint = extractConstraint(attr); + if (constraint) { + addConstraint(constraint); + } + } + + // then field-level @unique for (const field of model.fields) { - if (hasAttribute(field, '@id') || hasAttribute(field, '@unique')) { - constraints.push({ name: field.name, fields: [field.name] }); + if (hasAttribute(field, '@unique')) { + addConstraint({ name: field.name, fields: [field.name] }); + } + } + + // then model-level @@unique + for (const attr of model.attributes.filter((attr) => attr.decl.ref?.name === '@@unique')) { + const constraint = extractConstraint(attr); + if (constraint) { + addConstraint(constraint); } } diff --git a/packages/server/package.json b/packages/server/package.json index 9cdfbeda5..39a34bf39 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.12.3", + "version": "1.12.4", "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 0b6907977..02c05d54a 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.12.3", + "version": "1.12.4", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/tests/integration/tests/enhancements/with-policy/multi-id-fields.test.ts b/tests/integration/tests/enhancements/with-policy/multi-id-fields.test.ts index f48cdba45..0abb45559 100644 --- a/tests/integration/tests/enhancements/with-policy/multi-id-fields.test.ts +++ b/tests/integration/tests/enhancements/with-policy/multi-id-fields.test.ts @@ -12,8 +12,8 @@ describe('With Policy: multiple id fields', () => { process.chdir(origDir); }); - it('multi-id fields', async () => { - const { prisma, withPolicy } = await loadSchema( + it('multi-id fields crud', async () => { + const { prisma, enhance } = await loadSchema( ` model A { x String @@ -43,7 +43,7 @@ describe('With Policy: multiple id fields', () => { ` ); - const db = withPolicy(); + const db = enhance(); await expect(db.a.create({ data: { x: '1', y: 1, value: 0 } })).toBeRejectedByPolicy(); await expect(db.a.create({ data: { x: '1', y: 2, value: 1 } })).toResolveTruthy(); @@ -69,8 +69,77 @@ describe('With Policy: multiple id fields', () => { ).toResolveTruthy(); }); + it('multi-id fields id update', async () => { + const { prisma, enhance } = await loadSchema( + ` + model A { + x String + y Int + value Int + b B? + @@id([x, y]) + + @@allow('read', true) + @@allow('create', value > 0) + @@allow('update', value > 0 && future().value > 1) + } + + model B { + b1 String + b2 String + value Int + a A @relation(fields: [ax, ay], references: [x, y]) + ax String + ay Int + + @@allow('read', value > 2) + @@allow('create', value > 1) + + @@unique([ax, ay]) + @@id([b1, b2]) + } + ` + ); + + const db = enhance(); + + await db.a.create({ data: { x: '1', y: 2, value: 1 } }); + + await expect( + db.a.update({ where: { x_y: { x: '1', y: 2 } }, data: { x: '2', y: 3, value: 0 } }) + ).toBeRejectedByPolicy(); + + await expect( + db.a.update({ where: { x_y: { x: '1', y: 2 } }, data: { x: '2', y: 3, value: 2 } }) + ).resolves.toMatchObject({ + x: '2', + y: 3, + value: 2, + }); + + await expect( + db.a.upsert({ + where: { x_y: { x: '2', y: 3 } }, + update: { x: '3', y: 4, value: 0 }, + create: { x: '4', y: 5, value: 5 }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.a.upsert({ + where: { x_y: { x: '2', y: 3 } }, + update: { x: '3', y: 4, value: 3 }, + create: { x: '4', y: 5, value: 5 }, + }) + ).resolves.toMatchObject({ + x: '3', + y: 4, + value: 3, + }); + }); + it('multi-id auth', async () => { - const { prisma, withPolicy } = await loadSchema( + const { prisma, enhance } = await loadSchema( ` model User { x String @@ -124,7 +193,7 @@ describe('With Policy: multiple id fields', () => { await prisma.user.create({ data: { x: '1', y: '1' } }); await prisma.user.create({ data: { x: '1', y: '2' } }); - const anonDb = withPolicy(); + const anonDb = enhance(); await expect( anonDb.m.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } }) @@ -139,7 +208,7 @@ describe('With Policy: multiple id fields', () => { anonDb.n.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } }) ).toBeRejectedByPolicy(); - const db = withPolicy({ x: '1', y: '1' }); + const db = enhance({ x: '1', y: '1' }); await expect(db.m.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } })).toBeRejectedByPolicy(); await expect(db.m.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } })).toResolveTruthy(); @@ -149,13 +218,13 @@ describe('With Policy: multiple id fields', () => { await expect(db.p.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } })).toResolveTruthy(); await expect( - withPolicy(undefined).q.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } }) + enhance(undefined).q.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } }) ).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( + const { enhance } = await loadSchema( ` model A { x Int @@ -177,7 +246,7 @@ describe('With Policy: multiple id fields', () => { } ` ); - const db = withPolicy(); + const db = enhance(); await expect( db.b.create({ data: { @@ -205,7 +274,7 @@ describe('With Policy: multiple id fields', () => { }); it('multi-id to-many nested write', async () => { - const { withPolicy } = await loadSchema( + const { enhance } = await loadSchema( ` model A { x Int @@ -237,7 +306,7 @@ describe('With Policy: multiple id fields', () => { } ` ); - const db = withPolicy(); + const db = enhance(); await expect( db.b.create({ data: { @@ -270,4 +339,79 @@ describe('With Policy: multiple id fields', () => { 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 })); }); + + it('multi-id fields nested id update', async () => { + const { enhance } = await loadSchema( + ` + model A { + x String + y Int + value Int + b B @relation(fields: [bId], references: [id]) + bId Int + @@id([x, y]) + + @@allow('read', true) + @@allow('create', value > 0) + @@allow('update', value > 0 && future().value > 1) + } + + model B { + id Int @id @default(autoincrement()) + a A[] + @@allow('all', true) + } + ` + ); + + const db = enhance(); + + await db.b.create({ data: { id: 1, a: { create: { x: '1', y: 1, value: 1 } } } }); + + await expect( + db.b.update({ + where: { id: 1 }, + data: { a: { update: { where: { x_y: { x: '1', y: 1 } }, data: { x: '2', y: 2, value: 0 } } } }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.b.update({ + where: { id: 1 }, + data: { a: { update: { where: { x_y: { x: '1', y: 1 } }, data: { x: '2', y: 2, value: 2 } } } }, + include: { a: true }, + }) + ).resolves.toMatchObject({ a: expect.arrayContaining([expect.objectContaining({ x: '2', y: 2, value: 2 })]) }); + + await expect( + db.b.update({ + where: { id: 1 }, + data: { + a: { + upsert: { + where: { x_y: { x: '2', y: 2 } }, + update: { x: '3', y: 3, value: 0 }, + create: { x: '4', y: '4', value: 4 }, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.b.update({ + where: { id: 1 }, + data: { + a: { + upsert: { + where: { x_y: { x: '2', y: 2 } }, + update: { x: '3', y: 3, value: 3 }, + create: { x: '4', y: '4', value: 4 }, + }, + }, + }, + include: { a: true }, + }) + ).resolves.toMatchObject({ a: expect.arrayContaining([expect.objectContaining({ x: '3', y: 3, value: 3 })]) }); + }); }); 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 b112aeeb1..664f3256f 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 @@ -284,6 +284,101 @@ describe('With Policy:nested to-many', () => { expect(r.m2).toEqual(expect.arrayContaining([expect.objectContaining({ id: '2', value: 3 })])); }); + it('update id field', 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 + + @@allow('read', true) + @@allow('create', true) + @@allow('update', value > 1 && future().value > 2) + } + ` + ); + + const db = withPolicy(); + + await db.m1.create({ + data: { + id: '1', + m2: { + create: { id: '1', value: 2 }, + }, + }, + }); + + await expect( + db.m1.update({ + where: { id: '1' }, + include: { m2: true }, + data: { + m2: { + update: { + where: { id: '1' }, + data: { id: '2', value: 1 }, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + let r = await db.m1.update({ + where: { id: '1' }, + include: { m2: true }, + data: { + m2: { + update: { + where: { id: '1' }, + data: { id: '2', value: 3 }, + }, + }, + }, + }); + expect(r.m2).toEqual(expect.arrayContaining([expect.objectContaining({ id: '2', value: 3 })])); + + await expect( + db.m1.update({ + where: { id: '1' }, + include: { m2: true }, + data: { + m2: { + upsert: { + where: { id: '2' }, + create: { id: '4', value: 4 }, + update: { id: '3', value: 1 }, + }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + r = await db.m1.update({ + where: { id: '1' }, + include: { m2: true }, + data: { + m2: { + upsert: { + where: { id: '2' }, + create: { id: '4', value: 4 }, + update: { id: '3', value: 4 }, + }, + }, + }, + }); + expect(r.m2).toEqual(expect.arrayContaining([expect.objectContaining({ id: '3', value: 4 })])); + }); + it('update with create from one to many', 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 2e14b6d02..c510e6bb5 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 @@ -212,6 +212,64 @@ describe('With Policy:nested to-one', () => { ).toBeRejectedByPolicy(); }); + it('nested update id tests', 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 @unique + + @@allow('read', true) + @@allow('create', value > 0) + @@allow('update', value > 1 && future().value > 2) + } + ` + ); + + const db = withPolicy(); + + await db.m1.create({ + data: { + id: '1', + m2: { + create: { id: '1', value: 2 }, + }, + }, + }); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + update: { id: '2', value: 1 }, + }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + update: { id: '2', value: 3 }, + }, + }, + include: { m2: true }, + }) + ).resolves.toMatchObject({ m2: expect.objectContaining({ id: '2', value: 3 }) }); + }); + it('nested create', async () => { const { withPolicy } = await loadSchema( ` diff --git a/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts b/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts index 99179e015..0ebf2a182 100644 --- a/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts +++ b/tests/integration/tests/enhancements/with-policy/toplevel-operations.test.ts @@ -147,6 +147,82 @@ describe('With Policy: toplevel operations', () => { ).toBeTruthy(); }); + it('update id tests', async () => { + const { withPolicy } = await loadSchema( + ` + model Model { + id String @id @default(uuid()) + value Int + + @@allow('read', value > 1) + @@allow('create', value > 0) + @@allow('update', value > 1 && future().value > 2) + } + ` + ); + + const db = withPolicy(); + + await db.model.create({ + data: { + id: '1', + value: 2, + }, + }); + + // update denied + await expect( + db.model.update({ + where: { id: '1' }, + data: { + id: '2', + value: 1, + }, + }) + ).toBeRejectedByPolicy(); + + // update success + await expect( + db.model.update({ + where: { id: '1' }, + data: { + id: '2', + value: 3, + }, + }) + ).resolves.toMatchObject({ id: '2', value: 3 }); + + // upsert denied + await expect( + db.model.upsert({ + where: { id: '2' }, + update: { + id: '3', + value: 1, + }, + create: { + id: '4', + value: 5, + }, + }) + ).toBeRejectedByPolicy(); + + // upsert success + await expect( + db.model.upsert({ + where: { id: '2' }, + update: { + id: '3', + value: 4, + }, + create: { + id: '4', + value: 5, + }, + }) + ).resolves.toMatchObject({ id: '3', value: 4 }); + }); + it('delete tests', async () => { const { withPolicy, prisma } = await loadSchema( ` diff --git a/tests/integration/tests/regression/issue-1271.test.ts b/tests/integration/tests/regression/issue-1271.test.ts new file mode 100644 index 000000000..d25cabb3b --- /dev/null +++ b/tests/integration/tests/regression/issue-1271.test.ts @@ -0,0 +1,192 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1271', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model User { + id String @id @default(uuid()) + + @@auth + @@allow('all', true) + } + + model Test { + id String @id @default(uuid()) + linkingTable LinkingTable[] + key String @default('test') + locale String @default('EN') + + @@unique([key, locale]) + @@allow("all", true) + } + + model LinkingTable { + test_id String + test Test @relation(fields: [test_id], references: [id]) + + another_test_id String + another_test AnotherTest @relation(fields: [another_test_id], references: [id]) + + @@id([test_id, another_test_id]) + @@allow("all", true) + } + + model AnotherTest { + id String @id @default(uuid()) + status String + linkingTable LinkingTable[] + + @@allow("all", true) + } + `, + { logPrismaQuery: true } + ); + + const db = enhance(); + + const test = await db.test.create({ + data: { + key: 'test1', + }, + }); + const anotherTest = await db.anotherTest.create({ + data: { + status: 'available', + }, + }); + + const updated = await db.test.upsert({ + where: { + key_locale: { + key: test.key, + locale: test.locale, + }, + }, + create: { + linkingTable: { + create: { + another_test_id: anotherTest.id, + }, + }, + }, + update: { + linkingTable: { + create: { + another_test_id: anotherTest.id, + }, + }, + }, + include: { + linkingTable: true, + }, + }); + + expect(updated.linkingTable).toHaveLength(1); + expect(updated.linkingTable[0]).toMatchObject({ another_test_id: anotherTest.id }); + + const test2 = await db.test.upsert({ + where: { + key_locale: { + key: 'test2', + locale: 'locale2', + }, + }, + create: { + key: 'test2', + locale: 'locale2', + linkingTable: { + create: { + another_test_id: anotherTest.id, + }, + }, + }, + update: { + linkingTable: { + create: { + another_test_id: anotherTest.id, + }, + }, + }, + include: { + linkingTable: true, + }, + }); + expect(test2).toMatchObject({ key: 'test2', locale: 'locale2' }); + expect(test2.linkingTable).toHaveLength(1); + expect(test2.linkingTable[0]).toMatchObject({ another_test_id: anotherTest.id }); + + const linkingTable = test2.linkingTable[0]; + + // connectOrCreate: connect case + const test3 = await db.test.create({ + data: { + key: 'test3', + locale: 'locale3', + }, + }); + console.log('test3 created:', test3); + const updated2 = await db.linkingTable.update({ + where: { + test_id_another_test_id: { + test_id: linkingTable.test_id, + another_test_id: linkingTable.another_test_id, + }, + }, + data: { + test: { + connectOrCreate: { + where: { + key_locale: { + key: test3.key, + locale: test3.locale, + }, + }, + create: { + key: 'test4', + locale: 'locale4', + }, + }, + }, + another_test: { connect: { id: anotherTest.id } }, + }, + include: { test: true }, + }); + expect(updated2).toMatchObject({ + test: expect.objectContaining({ key: 'test3', locale: 'locale3' }), + another_test_id: anotherTest.id, + }); + + // connectOrCreate: create case + const updated3 = await db.linkingTable.update({ + where: { + test_id_another_test_id: { + test_id: updated2.test_id, + another_test_id: updated2.another_test_id, + }, + }, + data: { + test: { + connectOrCreate: { + where: { + key_locale: { + key: 'test4', + locale: 'locale4', + }, + }, + create: { + key: 'test4', + locale: 'locale4', + }, + }, + }, + another_test: { connect: { id: anotherTest.id } }, + }, + include: { test: true }, + }); + expect(updated3).toMatchObject({ + test: expect.objectContaining({ key: 'test4', locale: 'locale4' }), + another_test_id: anotherTest.id, + }); + }); +});