diff --git a/package.json b/package.json index 8dd8187f8..d622deade 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.7.0", + "version": "1.7.1", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index a2e12dcfa..95c926cd6 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "dev.zenstack" -version = "1.7.0" +version = "1.7.1" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index 66f709504..f4c6cb59b 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "1.7.0", + "version": "1.7.1", "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 9bf8f5505..bde19396a 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.7.0", + "version": "1.7.1", "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 362c68960..9f9537ee8 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.7.0", + "version": "1.7.1", "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 bb8efab3f..8969a2c06 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.7.0", + "version": "1.7.1", "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 ffe674d19..d3e246a1c 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.7.0", + "version": "1.7.1", "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 dcbbc774e..efd031243 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.7.0", + "version": "1.7.1", "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 747c70d2f..4d72c799b 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.7.0", + "version": "1.7.1", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7ecc6b964..7a2ac77f9 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.7.0", + "version": "1.7.1", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/cross/model-meta.ts b/packages/runtime/src/cross/model-meta.ts index 817819b8c..a38f7986d 100644 --- a/packages/runtime/src/cross/model-meta.ts +++ b/packages/runtime/src/cross/model-meta.ts @@ -66,6 +66,11 @@ export type FieldInfo = { * Mapping from foreign key field names to relation field names */ foreignKeyMapping?: Record; + + /** + * If the field is an auto-increment field + */ + isAutoIncrement?: boolean; }; /** diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index 9268c24b3..d35d36af2 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -523,29 +523,16 @@ export class PolicyProxyHandler implements Pr let createResult = await Promise.all( enumerate(args.data).map(async (item) => { if (args.skipDuplicates) { - // check unique constraint conflicts - // we can't rely on try/catch/ignore constraint violation error: https://github.com/prisma/prisma/issues/20496 - // TODO: for simple cases we should be able to translate it to an `upsert` with empty `update` payload - - // for each unique constraint, check if the input item has all fields set, and if so, check if - // an entity already exists, and ignore accordingly - const uniqueConstraints = this.utils.getUniqueConstraints(model); - for (const constraint of Object.values(uniqueConstraints)) { - if (constraint.fields.every((f) => item[f] !== undefined)) { - const uniqueFilter = constraint.fields.reduce((acc, f) => ({ ...acc, [f]: item[f] }), {}); - const existing = await this.utils.checkExistence(db, model, uniqueFilter); - if (existing) { - if (this.shouldLogQuery) { - this.logger.info(`[policy] skipping duplicate ${formatObject(item)}`); - } - return undefined; - } + if (await this.hasDuplicatedUniqueConstraint(model, item, db)) { + if (this.shouldLogQuery) { + this.logger.info(`[policy] \`createMany\` skipping duplicate ${formatObject(item)}`); } + return undefined; } } if (this.shouldLogQuery) { - this.logger.info(`[policy] \`create\` ${model}: ${formatObject(item)}`); + this.logger.info(`[policy] \`create\` for \`createMany\` ${model}: ${formatObject(item)}`); } return await db[model].create({ select: this.utils.makeIdSelection(model), data: item }); }) @@ -564,6 +551,26 @@ export class PolicyProxyHandler implements Pr }; } + private async hasDuplicatedUniqueConstraint(model: string, createData: any, db: Record) { + // check unique constraint conflicts + // we can't rely on try/catch/ignore constraint violation error: https://github.com/prisma/prisma/issues/20496 + // TODO: for simple cases we should be able to translate it to an `upsert` with empty `update` payload + + // for each unique constraint, check if the input item has all fields set, and if so, check if + // an entity already exists, and ignore accordingly + const uniqueConstraints = this.utils.getUniqueConstraints(model); + for (const constraint of Object.values(uniqueConstraints)) { + if (constraint.fields.every((f) => createData[f] !== undefined)) { + const uniqueFilter = constraint.fields.reduce((acc, f) => ({ ...acc, [f]: createData[f] }), {}); + const existing = await this.utils.checkExistence(db, model, uniqueFilter); + if (existing) { + return true; + } + } + } + return false; + } + //#endregion //#region Update & Upsert @@ -707,17 +714,22 @@ export class PolicyProxyHandler implements Pr postWriteChecks.push(...checks); }; - const _createMany = async (model: string, args: any, context: NestedWriteVisitorContext) => { - if (context.field?.backLink) { - // handles the connection to upstream entity - const reversedQuery = this.utils.buildReversedQuery(context); - for (const item of enumerate(args.data)) { - Object.assign(item, reversedQuery); + const _createMany = async ( + model: string, + args: { data: any; skipDuplicates?: boolean }, + context: NestedWriteVisitorContext + ) => { + for (const item of enumerate(args.data)) { + if (args.skipDuplicates) { + if (await this.hasDuplicatedUniqueConstraint(model, item, db)) { + if (this.shouldLogQuery) { + this.logger.info(`[policy] \`createMany\` skipping duplicate ${formatObject(item)}`); + } + continue; + } } + await _create(model, item, context); } - // proceed with the create and collect post-create checks - const { postWriteChecks: checks } = await this.doCreateMany(model, args, db); - postWriteChecks.push(...checks); }; const _connectDisconnect = async (model: string, args: any, context: NestedWriteVisitorContext) => { @@ -797,9 +809,6 @@ export class PolicyProxyHandler implements Pr }, updateMany: async (model, args, context) => { - // injects auth guard into where clause - this.utils.injectAuthGuard(db, args, model, 'update'); - // prepare for post-update check if (this.utils.hasAuthGuard(model, 'postUpdate') || this.utils.getZodSchema(model)) { let select = this.utils.makeIdSelection(model); @@ -809,10 +818,12 @@ export class PolicyProxyHandler implements Pr } const reversedQuery = this.utils.buildReversedQuery(context); const currentSetQuery = { select, where: reversedQuery }; - this.utils.injectAuthGuard(db, currentSetQuery, model, 'read'); + this.utils.injectAuthGuardAsWhere(db, currentSetQuery, model, 'read'); if (this.shouldLogQuery) { - this.logger.info(`[policy] \`findMany\` ${model}:\n${formatObject(currentSetQuery)}`); + this.logger.info( + `[policy] \`findMany\` for post update check ${model}:\n${formatObject(currentSetQuery)}` + ); } const currentSet = await db[model].findMany(currentSetQuery); @@ -825,6 +836,27 @@ export class PolicyProxyHandler implements Pr })) ); } + + const updateGuard = this.utils.getAuthGuard(db, model, 'update'); + if (this.utils.isTrue(updateGuard) || this.utils.isFalse(updateGuard)) { + // injects simple auth guard into where clause + this.utils.injectAuthGuardAsWhere(db, args, model, 'update'); + } else { + // we have to process `updateMany` separately because the guard may contain + // filters using relation fields which are not allowed in nested `updateMany` + const reversedQuery = this.utils.buildReversedQuery(context); + const updateWhere = this.utils.and(reversedQuery, updateGuard); + if (this.shouldLogQuery) { + this.logger.info( + `[policy] \`updateMany\` ${model}:\n${formatObject({ + where: updateWhere, + data: args.data, + })}` + ); + } + await db[model].updateMany({ where: updateWhere, data: args.data }); + delete context.parent.updateMany; + } }, create: async (model, args, context) => { @@ -931,9 +963,21 @@ export class PolicyProxyHandler implements Pr }, deleteMany: async (model, args, context) => { - // inject delete guard const guard = await this.utils.getAuthGuard(db, model, 'delete'); - context.parent.deleteMany = this.utils.and(args, guard); + if (this.utils.isTrue(guard) || this.utils.isFalse(guard)) { + // inject simple auth guard + context.parent.deleteMany = this.utils.and(args, guard); + } else { + // we have to process `deleteMany` separately because the guard may contain + // filters using relation fields which are not allowed in nested `deleteMany` + const reversedQuery = this.utils.buildReversedQuery(context); + const deleteWhere = this.utils.and(reversedQuery, guard); + if (this.shouldLogQuery) { + this.logger.info(`[policy] \`deleteMany\` ${model}:\n${formatObject({ where: deleteWhere })}`); + } + await db[model].deleteMany({ where: deleteWhere }); + delete context.parent.deleteMany; + } }, }); @@ -958,13 +1002,17 @@ export class PolicyProxyHandler implements Pr } for (const k of Object.keys(args)) { const field = resolveField(this.modelMeta, model, k); - if (field?.isId || field?.isForeignKey) { + if (this.isAutoIncrementIdField(field) || field?.isForeignKey) { return true; } } return false; } + private isAutoIncrementIdField(field: FieldInfo) { + return field.isId && field.isAutoIncrement; + } + async updateMany(args: any) { if (!args) { throw prismaClientValidationError(this.prisma, 'query argument is required'); @@ -976,7 +1024,7 @@ export class PolicyProxyHandler implements Pr this.utils.tryReject(this.prisma, this.model, 'update'); args = this.utils.clone(args); - this.utils.injectAuthGuard(this.prisma, args, this.model, 'update'); + this.utils.injectAuthGuardAsWhere(this.prisma, args, this.model, 'update'); if (this.utils.hasAuthGuard(this.model, 'postUpdate') || this.utils.getZodSchema(this.model)) { // use a transaction to do post-update checks @@ -989,7 +1037,7 @@ export class PolicyProxyHandler implements Pr select = { ...select, ...preValueSelect }; } const currentSetQuery = { select, where: args.where }; - this.utils.injectAuthGuard(tx, currentSetQuery, this.model, 'read'); + this.utils.injectAuthGuardAsWhere(tx, currentSetQuery, this.model, 'read'); if (this.shouldLogQuery) { this.logger.info(`[policy] \`findMany\` ${this.model}: ${formatObject(currentSetQuery)}`); @@ -1118,7 +1166,7 @@ export class PolicyProxyHandler implements Pr // inject policy conditions args = args ?? {}; - this.utils.injectAuthGuard(this.prisma, args, this.model, 'delete'); + this.utils.injectAuthGuardAsWhere(this.prisma, args, this.model, 'delete'); // conduct the deletion if (this.shouldLogQuery) { @@ -1139,7 +1187,7 @@ export class PolicyProxyHandler implements Pr args = this.utils.clone(args); // inject policy conditions - this.utils.injectAuthGuard(this.prisma, args, this.model, 'read'); + this.utils.injectAuthGuardAsWhere(this.prisma, args, this.model, 'read'); if (this.shouldLogQuery) { this.logger.info(`[policy] \`aggregate\` ${this.model}:\n${formatObject(args)}`); @@ -1155,7 +1203,7 @@ export class PolicyProxyHandler implements Pr args = this.utils.clone(args); // inject policy conditions - this.utils.injectAuthGuard(this.prisma, args, this.model, 'read'); + this.utils.injectAuthGuardAsWhere(this.prisma, args, this.model, 'read'); if (this.shouldLogQuery) { this.logger.info(`[policy] \`groupBy\` ${this.model}:\n${formatObject(args)}`); @@ -1166,7 +1214,7 @@ export class PolicyProxyHandler implements Pr async count(args: any) { // inject policy conditions args = args ? this.utils.clone(args) : {}; - this.utils.injectAuthGuard(this.prisma, args, this.model, 'read'); + this.utils.injectAuthGuardAsWhere(this.prisma, args, this.model, 'read'); if (this.shouldLogQuery) { this.logger.info(`[policy] \`count\` ${this.model}:\n${formatObject(args)}`); diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index d6f9595b2..388f9cd90 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -355,7 +355,7 @@ export class PolicyUtil { /** * Injects model auth guard as where clause. */ - injectAuthGuard(db: Record, args: any, model: string, operation: PolicyOperationKind) { + injectAuthGuardAsWhere(db: Record, args: any, model: string, operation: PolicyOperationKind) { let guard = this.getAuthGuard(db, model, operation); if (operation === 'update' && args) { @@ -494,7 +494,7 @@ export class PolicyUtil { injectForRead(db: Record, model: string, args: any) { // make select and include visible to the injection const injected: any = { select: args.select, include: args.include }; - if (!this.injectAuthGuard(db, injected, model, 'read')) { + if (!this.injectAuthGuardAsWhere(db, injected, model, 'read')) { return false; } @@ -562,7 +562,7 @@ export class PolicyUtil { /** * Builds a reversed query for the given nested path. */ - buildReversedQuery(context: NestedWriteVisitorContext, mutating = false, unsafeOperation = false) { + buildReversedQuery(context: NestedWriteVisitorContext, forMutationPayload = false, unsafeOperation = false) { let result, currQuery: any; let currField: FieldInfo | undefined; @@ -593,7 +593,7 @@ export class PolicyUtil { throw this.unknownError(`missing backLink field ${currField.backLink} in ${currField.type}`); } - if (backLinkField.isArray && !mutating) { + if (backLinkField.isArray && !forMutationPayload) { // many-side of relationship, wrap with "some" query currQuery[currField.backLink] = { some: { ...visitWhere } }; currQuery = currQuery[currField.backLink].some; @@ -603,7 +603,7 @@ export class PolicyUtil { // calculate if we should preserve the relation condition (e.g., { user: { id: 1 } }) const shouldPreserveRelationCondition = // doing a mutation - mutating && + forMutationPayload && // and it's a safe mutate !unsafeOperation && // and the current segment is the direct parent (the last one is the mutate itself), @@ -666,7 +666,7 @@ export class PolicyUtil { continue; } // inject into the "where" clause inside select - this.injectAuthGuard(db, injectTarget._count.select[field], fieldInfo.type, 'read'); + this.injectAuthGuardAsWhere(db, injectTarget._count.select[field], fieldInfo.type, 'read'); } } @@ -692,7 +692,7 @@ export class PolicyUtil { injectTarget[field] = {}; } // inject extra condition for to-many or nullable to-one relation - this.injectAuthGuard(db, injectTarget[field], fieldInfo.type, 'read'); + this.injectAuthGuardAsWhere(db, injectTarget[field], fieldInfo.type, 'read'); // recurse const subHoisted = this.injectNestedReadConditions(db, fieldInfo.type, injectTarget[field]); diff --git a/packages/schema/package.json b/packages/schema/package.json index 0bff82eac..c9ae37484 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.7.0", + "version": "1.7.1", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 2d01dcd12..878d3fa5f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.7.0", + "version": "1.7.1", "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 41a0ea0c9..99029e610 100644 --- a/packages/sdk/src/model-meta-generator.ts +++ b/packages/sdk/src/model-meta-generator.ts @@ -5,6 +5,7 @@ import { isArrayExpr, isBooleanLiteral, isDataModel, + isInvocationExpr, isNumberLiteral, isReferenceExpr, isStringLiteral, @@ -130,6 +131,11 @@ function generateModelMetadata(dataModels: DataModel[], writer: CodeBlockWriter, foreignKeyMapping: ${JSON.stringify(fkMapping)},`); } + if (isAutoIncrement(f)) { + writer.write(` + isAutoIncrement: true,`); + } + writer.write(` },`); } @@ -338,3 +344,17 @@ function getDeleteCascades(model: DataModel): string[] { }) .map((m) => m.name); } + +function isAutoIncrement(field: DataModelField) { + const defaultAttr = getAttribute(field, '@default'); + if (!defaultAttr) { + return false; + } + + const arg = defaultAttr.args[0]?.value; + if (!arg) { + return false; + } + + return isInvocationExpr(arg) && arg.function.$refText === 'autoincrement'; +} diff --git a/packages/server/package.json b/packages/server/package.json index 5a27e68f9..9f13ec6ed 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.7.0", + "version": "1.7.1", "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 c1e443111..66ac699be 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.7.0", + "version": "1.7.1", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/tests/integration/tests/enhancements/with-policy/deep-nested.test.ts b/tests/integration/tests/enhancements/with-policy/deep-nested.test.ts index 9608f9c62..ee8f16467 100644 --- a/tests/integration/tests/enhancements/with-policy/deep-nested.test.ts +++ b/tests/integration/tests/enhancements/with-policy/deep-nested.test.ts @@ -294,7 +294,7 @@ describe('With Policy:deep nested', () => { expect(r2.m2.m3).toBeNull(); }); - it('update', async () => { + it('update simple nested', async () => { await db.m1.create({ data: { myId: '1' }, }); @@ -440,6 +440,142 @@ describe('With Policy:deep nested', () => { expect(r2.m2.m3).toBeNull(); }); + it('update createMany/updateMany/deleteMany nested', async () => { + await db.m1.create({ + data: { + myId: '1', + m2: { + create: { + value: 2, + }, + }, + }, + }); + + // createMany with duplicate + await expect( + db.m1.update({ + where: { myId: '1' }, + data: { + m2: { + update: { + m4: { + createMany: { + data: [ + { id: 'm4-1', value: 21 }, + { id: 'm4-1', value: 22 }, + ], + }, + }, + }, + }, + }, + }) + ).rejects.toThrow('Unique constraint failed'); + + // createMany skip duplicate + await db.m1.update({ + where: { myId: '1' }, + data: { + m2: { + update: { + m4: { + createMany: { + skipDuplicates: true, + data: [ + { id: 'm4-1', value: 21 }, + { id: 'm4-1', value: 211 }, + { id: 'm4-2', value: 22 }, + ], + }, + }, + }, + }, + }, + }); + await expect(db.m4.findMany()).resolves.toHaveLength(2); + + // updateMany, filtered out by policy + await db.m1.update({ + where: { myId: '1' }, + data: { + m2: { + update: { + m4: { + updateMany: { + where: { + id: 'm4-1', + }, + data: { + value: 210, + }, + }, + }, + }, + }, + }, + }); + await expect(db.m4.findUnique({ where: { id: 'm4-1' } })).resolves.toMatchObject({ value: 21 }); + await expect(db.m4.findUnique({ where: { id: 'm4-2' } })).resolves.toMatchObject({ value: 22 }); + + // updateMany, success + await db.m1.update({ + where: { myId: '1' }, + data: { + m2: { + update: { + m4: { + updateMany: { + where: { + id: 'm4-2', + }, + data: { + value: 220, + }, + }, + }, + }, + }, + }, + }); + await expect(db.m4.findUnique({ where: { id: 'm4-1' } })).resolves.toMatchObject({ value: 21 }); + await expect(db.m4.findUnique({ where: { id: 'm4-2' } })).resolves.toMatchObject({ value: 220 }); + + // deleteMany, filtered out by policy + await db.m1.update({ + where: { myId: '1' }, + data: { + m2: { + update: { + m4: { + deleteMany: { + id: 'm4-1', + }, + }, + }, + }, + }, + }); + await expect(db.m4.findMany()).resolves.toHaveLength(2); + + // deleteMany, success + await db.m1.update({ + where: { myId: '1' }, + data: { + m2: { + update: { + m4: { + deleteMany: { + id: 'm4-2', + }, + }, + }, + }, + }, + }); + await expect(db.m4.findMany()).resolves.toHaveLength(1); + }); + it('delete', async () => { await db.m1.create({ data: { diff --git a/tests/integration/tests/regression/issue-961.test.ts b/tests/integration/tests/regression/issue-961.test.ts new file mode 100644 index 000000000..7bc42071b --- /dev/null +++ b/tests/integration/tests/regression/issue-961.test.ts @@ -0,0 +1,212 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('Regression: issue 961', () => { + const schema = ` + model User { + id String @id @default(cuid()) + backups UserColumnBackup[] + } + + model UserColumnBackup { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + key String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt() + columns UserColumn[] + @@unique([userId, key]) + @@allow('all', auth().id == userId) + } + + model UserColumn { + id String @id @default(cuid()) + userColumnBackup UserColumnBackup @relation(fields: [userColumnBackupId], references: [id], onDelete: Cascade) + userColumnBackupId String + column String + version Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt() + + @@unique([userColumnBackupId, column]) + @@allow('all', auth().id == userColumnBackup.userId) + @@deny('update,delete', column == 'c2') + } + `; + + it('deleteMany', async () => { + const { prisma, enhance } = await loadSchema(schema, { logPrismaQuery: true }); + + const user = await prisma.user.create({ + data: { + backups: { + create: { + key: 'key1', + columns: { + create: [{ column: 'c1' }, { column: 'c2' }, { column: 'c3' }], + }, + }, + }, + }, + include: { backups: true }, + }); + const backup = user.backups[0]; + + const db = enhance({ id: user.id }); + + // delete with non-existing outer filter + await expect( + db.userColumnBackup.update({ + where: { id: 'abc' }, + data: { + columns: { + deleteMany: { + column: 'c1', + }, + }, + }, + }) + ).toBeNotFound(); + await expect(db.userColumn.findMany()).resolves.toHaveLength(3); + + // delete c1 + await db.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c1', + }, + }, + }, + include: { columns: true }, + }); + await expect(db.userColumn.findMany()).resolves.toHaveLength(2); + + // delete c1 again, no change + await db.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c1', + }, + }, + }, + }); + await expect(db.userColumn.findMany()).resolves.toHaveLength(2); + + // delete c2, filtered out by policy + await db.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c2', + }, + }, + }, + }); + await expect(db.userColumn.findMany()).resolves.toHaveLength(2); + + // delete c3, should succeed + await db.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c3', + }, + }, + }, + }); + await expect(db.userColumn.findMany()).resolves.toHaveLength(1); + }); + + // disabled because of Prisma V4 bug: https://github.com/prisma/prisma/issues/18371 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('updateMany', async () => { + const { prisma, enhance } = await loadSchema(schema, { logPrismaQuery: true }); + + const user = await prisma.user.create({ + data: { + backups: { + create: { + key: 'key1', + columns: { + create: [ + { column: 'c1', version: 1 }, + { column: 'c2', version: 2 }, + ], + }, + }, + }, + }, + include: { backups: true }, + }); + const backup = user.backups[0]; + + const db = enhance({ id: user.id }); + + // update with non-existing outer filter + await expect( + db.userColumnBackup.update({ + where: { id: 'abc' }, + data: { + columns: { + updateMany: { + where: { column: 'c1' }, + data: { version: { increment: 1 } }, + }, + }, + }, + }) + ).toBeNotFound(); + await expect(db.userColumn.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ column: 'c1', version: 1 }), + expect.objectContaining({ column: 'c2', version: 2 }), + ]) + ); + + // update c1 + await db.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + updateMany: { + where: { column: 'c1' }, + data: { version: { increment: 1 } }, + }, + }, + }, + include: { columns: true }, + }); + await expect(db.userColumn.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ column: 'c1', version: 2 }), + expect.objectContaining({ column: 'c2', version: 2 }), + ]) + ); + + // update c2, filtered out by policy + await db.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + updateMany: { + where: { column: 'c2' }, + data: { version: { increment: 1 } }, + }, + }, + }, + include: { columns: true }, + }); + await expect(db.userColumn.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ column: 'c1', version: 2 }), + expect.objectContaining({ column: 'c2', version: 2 }), + ]) + ); + }); +});