From 606adb5763a6313231e5e17bbd721eea89023792 Mon Sep 17 00:00:00 2001 From: Dylan Lundy <4567380+diesal11@users.noreply.github.com> Date: Tue, 18 Mar 2025 15:09:09 +1030 Subject: [PATCH 01/10] fix(zod): Required fields with a default value should be optional in Typed JSON cases (#2044) --- .../schema/src/plugins/zod/transformer.ts | 12 ++++++---- tests/integration/tests/plugins/zod.test.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index 899c6c473..b8669e8e2 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -282,22 +282,24 @@ export default class Transformer { const fieldName = alternatives.some((alt) => alt.includes(':')) ? '' : ` ${field.name}:`; - const opt = !field.isRequired ? '.optional()' : ''; - let resString: string; if (alternatives.length === 1) { - resString = alternatives.join(',\r\n'); + resString = alternatives[0]; } else { if (alternatives.some((alt) => alt.includes('Unchecked'))) { // if the union is for combining checked and unchecked input types, use `smartUnion` // to parse with the best candidate at runtime - resString = this.wrapWithSmartUnion(...alternatives) + `${opt}`; + resString = this.wrapWithSmartUnion(...alternatives); } else { - resString = `z.union([${alternatives.join(',\r\n')}])${opt}`; + resString = `z.union([${alternatives.join(',\r\n')}])`; } } + if (!field.isRequired) { + resString += '.optional()'; + } + if (field.isNullable) { resString += '.nullable()'; } diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts index 226da18f7..154101c31 100644 --- a/tests/integration/tests/plugins/zod.test.ts +++ b/tests/integration/tests/plugins/zod.test.ts @@ -1097,4 +1097,28 @@ describe('Zod plugin tests', () => { expect(schemas.UserSchema.safeParse({ id: 1, email: 'a@b.com' }).success).toBeTruthy(); expect(schemas.UserPrismaCreateSchema.safeParse({ email: 'a@b.com' }).success).toBeTruthy(); }); + + it('@json fields with @default should be optional', async () => { + const { zodSchemas } = await loadSchema( + ` + type Foo { + a String + } + + model Bar { + id Int @id @default(autoincrement()) + foo Foo @json @default("{ \\"a\\": \\"a\\" }") + fooList Foo[] @json @default("[]") + } + `, + { + fullZod: true, + provider: 'postgresql', + pushDb: false, + } + ); + + // Ensure Zod Schemas correctly mark @default fields as optional + expect(zodSchemas.objects.BarCreateInputObjectSchema.safeParse({}).success).toBeTruthy(); + }); }); From 6d3d8020af4f7aa559d354aa4d22168f16f91afd Mon Sep 17 00:00:00 2001 From: Youssef Gaber <1728215+Gabrola@users.noreply.github.com> Date: Fri, 21 Mar 2025 00:43:01 +0200 Subject: [PATCH 02/10] fix(runtime): properly re-export type declarations of model metadata (#2047) --- packages/runtime/res/model-meta.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/res/model-meta.d.ts b/packages/runtime/res/model-meta.d.ts index faac80c52..fbda166b2 100644 --- a/packages/runtime/res/model-meta.d.ts +++ b/packages/runtime/res/model-meta.d.ts @@ -1 +1 @@ -export * from '.zenstack/model-meta'; +export { default } from '.zenstack/model-meta'; From 57c61203c2f2547b5fd427abc8667cf96b81891c Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 20 Mar 2025 17:36:20 -0700 Subject: [PATCH 03/10] fix: generate `Enhanced` type helper even without logical client (#2049) --- packages/schema/src/plugins/enhancer/enhance/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/schema/src/plugins/enhancer/enhance/index.ts b/packages/schema/src/plugins/enhancer/enhance/index.ts index 585d35cb1..5fb7429a1 100644 --- a/packages/schema/src/plugins/enhancer/enhance/index.ts +++ b/packages/schema/src/plugins/enhancer/enhance/index.ts @@ -207,6 +207,13 @@ ${ return `import { Prisma, type PrismaClient } from '${prismaImport}'; import type * as _P from '${prismaImport}'; export type { PrismaClient }; + +/** + * Infers the type of PrismaClient with ZenStack's enhancements. + * @example + * type EnhancedPrismaClient = Enhanced; + */ +export type Enhanced = Client; `; } From 1369f9ec37967fd8bd90a75b4972d52009625296 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 20 Mar 2025 18:05:42 -0700 Subject: [PATCH 04/10] fix: allow using `currentModel()` and `currentOperation` calls in policy rules (#2050) --- .../function-invocation-validator.ts | 43 ++++++++++---- packages/sdk/src/validation.ts | 2 +- tests/regression/tests/issue-1984.test.ts | 57 +++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 tests/regression/tests/issue-1984.test.ts diff --git a/packages/schema/src/language-server/validator/function-invocation-validator.ts b/packages/schema/src/language-server/validator/function-invocation-validator.ts index 343c75cad..eff614e3c 100644 --- a/packages/schema/src/language-server/validator/function-invocation-validator.ts +++ b/packages/schema/src/language-server/validator/function-invocation-validator.ts @@ -11,6 +11,7 @@ import { isDataModel, isDataModelAttribute, isDataModelFieldAttribute, + isInvocationExpr, isLiteralExpr, } from '@zenstackhq/language/ast'; import { @@ -21,6 +22,7 @@ import { isDataModelFieldReference, isEnumFieldReference, isFromStdlib, + isValidationAttribute, } from '@zenstackhq/sdk'; import { AstNode, streamAst, ValidationAcceptor } from 'langium'; import { match, P } from 'ts-pattern'; @@ -70,20 +72,21 @@ export default class FunctionInvocationValidator implements AstValidator ExpressionContext.DefaultValue) - .with(P.union('@@allow', '@@deny', '@allow', '@deny'), () => ExpressionContext.AccessPolicy) - .with('@@validate', () => ExpressionContext.ValidationRule) - .with('@@index', () => ExpressionContext.Index) - .otherwise(() => undefined); + const exprContext = this.getExpressionContext(containerAttribute); // get the context allowed for the function const funcAllowedContext = getFunctionExpressionContext(funcDecl); - if (exprContext && !funcAllowedContext.includes(exprContext)) { - accept('error', `function "${funcDecl.name}" is not allowed in the current context: ${exprContext}`, { - node: expr, - }); + if (funcAllowedContext.length > 0 && (!exprContext || !funcAllowedContext.includes(exprContext))) { + accept( + 'error', + `function "${funcDecl.name}" is not allowed in the current context${ + exprContext ? ': ' + exprContext : '' + }`, + { + node: expr, + } + ); return; } @@ -121,6 +124,8 @@ export default class FunctionInvocationValidator implements AstValidator ExpressionContext.DefaultValue) + .with(P.union('@@allow', '@@deny', '@allow', '@deny'), () => ExpressionContext.AccessPolicy) + .with('@@index', () => ExpressionContext.Index) + .otherwise(() => undefined); + } + + private isStaticFunctionCall(expr: Expression) { + return isInvocationExpr(expr) && ['currentModel', 'currentOperation'].includes(expr.function.$refText); + } + private validateArgs(funcDecl: FunctionDecl, args: Argument[], accept: ValidationAcceptor) { let success = true; for (let i = 0; i < funcDecl.params.length; i++) { diff --git a/packages/sdk/src/validation.ts b/packages/sdk/src/validation.ts index 9dbcd8f5c..1c0923f63 100644 --- a/packages/sdk/src/validation.ts +++ b/packages/sdk/src/validation.ts @@ -7,7 +7,7 @@ import { type TypeDef, } from './ast'; -function isValidationAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { +export function isValidationAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { return attr.decl.ref?.attributes.some((attr) => attr.decl.$refText === '@@@validation'); } diff --git a/tests/regression/tests/issue-1984.test.ts b/tests/regression/tests/issue-1984.test.ts new file mode 100644 index 000000000..7792e9e26 --- /dev/null +++ b/tests/regression/tests/issue-1984.test.ts @@ -0,0 +1,57 @@ +import { loadModel, loadModelWithError, loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1984', () => { + it('regression1', async () => { + const { enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + access String + + @@allow('all', + contains(auth().access, currentModel()) || + contains(auth().access, currentOperation())) + } + ` + ); + + const db1 = enhance(); + await expect(db1.user.create({ data: { access: 'foo' } })).toBeRejectedByPolicy(); + + const db2 = enhance({ id: 1, access: 'aUser' }); + await expect(db2.user.create({ data: { access: 'aUser' } })).toResolveTruthy(); + + const db3 = enhance({ id: 1, access: 'do-create-read' }); + await expect(db3.user.create({ data: { access: 'do-create-read' } })).toResolveTruthy(); + + const db4 = enhance({ id: 1, access: 'do-read' }); + await expect(db4.user.create({ data: { access: 'do-read' } })).toBeRejectedByPolicy(); + }); + + it('regression2', async () => { + await expect( + loadModelWithError( + ` + model User { + id Int @id @default(autoincrement()) + modelName String + @@validate(contains(modelName, currentModel())) + } + ` + ) + ).resolves.toContain('function "currentModel" is not allowed in the current context: ValidationRule'); + }); + + it('regression3', async () => { + await expect( + loadModelWithError( + ` + model User { + id Int @id @default(autoincrement()) + modelName String @contains(currentModel()) + } + ` + ) + ).resolves.toContain('function "currentModel" is not allowed in the current context: ValidationRule'); + }); +}); From 9c37d4795eb3eab558b119c8c601f1956901c8ba Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 20 Mar 2025 20:39:12 -0700 Subject: [PATCH 05/10] fix(delegate): resolve from short name back to full name when processing delegate types (#2051) --- .../src/plugins/enhancer/enhance/index.ts | 38 ++++-- tests/regression/tests/issue-1994.test.ts | 111 ++++++++++++++++++ 2 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 tests/regression/tests/issue-1994.test.ts diff --git a/packages/schema/src/plugins/enhancer/enhance/index.ts b/packages/schema/src/plugins/enhancer/enhance/index.ts index 5fb7429a1..695853838 100644 --- a/packages/schema/src/plugins/enhancer/enhance/index.ts +++ b/packages/schema/src/plugins/enhancer/enhance/index.ts @@ -70,6 +70,9 @@ export class EnhancerGenerator { // Regex patterns for matching input/output types for models with JSON type fields private readonly modelsWithJsonTypeFieldsInputOutputPattern: RegExp[]; + // a mapping from shortened names to full names + private reversedShortNameMap = new Map(); + constructor( private readonly model: Model, private readonly options: PluginOptions, @@ -322,7 +325,7 @@ export type Enhanced = // calculate a relative output path to output the logical prisma client into enhancer's output dir const prismaClientOutDir = path.join(path.relative(zmodelDir, this.outDir), LOGICAL_CLIENT_GENERATION_PATH); - await prismaGenerator.generate({ + const generateResult = await prismaGenerator.generate({ provider: '@internal', // doesn't matter schemaPath: this.options.schemaPath, output: logicalPrismaFile, @@ -331,6 +334,11 @@ export type Enhanced = customAttributesAsComments: true, }); + // reverse direction of shortNameMap and store for future lookup + this.reversedShortNameMap = new Map( + Array.from(generateResult.shortNameMap.entries()).map(([key, value]) => [value, key]) + ); + // generate the prisma client // only run prisma client generator for the logical schema @@ -390,7 +398,7 @@ export type Enhanced = const createInputPattern = new RegExp(`^(.+?)(Unchecked)?Create.*Input$`); for (const inputType of dmmf.schema.inputObjectTypes.prisma) { const match = inputType.name.match(createInputPattern); - const modelName = match?.[1]; + const modelName = this.resolveName(match?.[1]); if (modelName) { const dataModel = this.model.declarations.find( (d): d is DataModel => isDataModel(d) && d.name === modelName @@ -673,7 +681,7 @@ export type Enhanced = const match = typeName.match(concreteCreateUpdateInputRegex); if (match) { - const modelName = match[1]; + const modelName = this.resolveName(match[1]); const dataModel = this.model.declarations.find( (d): d is DataModel => isDataModel(d) && d.name === modelName ); @@ -724,8 +732,9 @@ export type Enhanced = return source; } - const nameTuple = match[3]; // [modelName]_[relationFieldName]_[concreteModelName] - const [modelName, relationFieldName, _] = nameTuple.split('_'); + // [modelName]_[relationFieldName]_[concreteModelName] + const nameTuple = this.resolveName(match[3], true); + const [modelName, relationFieldName, _] = nameTuple!.split('_'); const fieldDef = this.findNamedProperty(typeAlias, relationFieldName); if (fieldDef) { @@ -769,13 +778,28 @@ export type Enhanced = return source; } + // resolves a potentially shortened name back to the original + private resolveName(name: string | undefined, withDelegateAuxPrefix = false) { + if (!name) { + return name; + } + const shortNameLookupKey = withDelegateAuxPrefix ? `${DELEGATE_AUX_RELATION_PREFIX}_${name}` : name; + if (this.reversedShortNameMap.has(shortNameLookupKey)) { + name = this.reversedShortNameMap.get(shortNameLookupKey)!; + if (withDelegateAuxPrefix) { + name = name.substring(DELEGATE_AUX_RELATION_PREFIX.length + 1); + } + } + return name; + } + private fixDefaultAuthType(typeAlias: TypeAliasDeclaration, source: string) { const match = typeAlias.getName().match(this.modelsWithAuthInDefaultCreateInputPattern); if (!match) { return source; } - const modelName = match[1]; + const modelName = this.resolveName(match[1]); const dataModel = this.model.declarations.find((d): d is DataModel => isDataModel(d) && d.name === modelName); if (dataModel) { for (const fkField of dataModel.fields.filter((f) => f.attributes.some(isDefaultWithAuth))) { @@ -831,7 +855,7 @@ export type Enhanced = continue; } // first capture group is the model name - const modelName = match[1]; + const modelName = this.resolveName(match[1]); const model = this.modelsWithJsonTypeFields.find((m) => m.name === modelName); const fieldsToFix = getTypedJsonFields(model!); for (const field of fieldsToFix) { diff --git a/tests/regression/tests/issue-1994.test.ts b/tests/regression/tests/issue-1994.test.ts new file mode 100644 index 000000000..e8fe40e62 --- /dev/null +++ b/tests/regression/tests/issue-1994.test.ts @@ -0,0 +1,111 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1994', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` +model OrganizationRole { + id Int @id @default(autoincrement()) + rolePrivileges OrganizationRolePrivilege[] + type String + @@delegate(type) +} + +model Organization { + id Int @id @default(autoincrement()) + customRoles CustomOrganizationRole[] +} + +// roles common to all orgs, defined once +model SystemDefinedRole extends OrganizationRole { + name String @unique +} + +// roles specific to each org +model CustomOrganizationRole extends OrganizationRole { + name String + organizationId Int + organization Organization @relation(fields: [organizationId], references: [id]) + + @@unique([organizationId, name]) + @@index([organizationId]) +} + +model OrganizationRolePrivilege { + organizationRoleId Int + privilegeId Int + + organizationRole OrganizationRole @relation(fields: [organizationRoleId], references: [id]) + privilege Privilege @relation(fields: [privilegeId], references: [id]) + + @@id([organizationRoleId, privilegeId]) +} + +model Privilege { + id Int @id @default(autoincrement()) + name String // e.g. "org:manage" + + orgRolePrivileges OrganizationRolePrivilege[] + @@unique([name]) +} + `, + { + enhancements: ['delegate'], + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` + import { PrismaClient } from '@prisma/client'; + import { enhance } from '.zenstack/enhance'; + + const prisma = new PrismaClient(); + + async function main() { + const db = enhance(prisma); + const privilege = await db.privilege.create({ + data: { name: 'org:manage' }, + }); + + await db.systemDefinedRole.create({ + data: { + name: 'Admin', + rolePrivileges: { + create: [ + { + privilegeId: privilege.id, + }, + ], + }, + }, + }); + } + main() + `, + }, + ], + } + ); + + const db = enhance(); + + const privilege = await db.privilege.create({ + data: { name: 'org:manage' }, + }); + + await expect( + db.systemDefinedRole.create({ + data: { + name: 'Admin', + rolePrivileges: { + create: [ + { + privilegeId: privilege.id, + }, + ], + }, + }, + }) + ).toResolveTruthy(); + }); +}); From 8438f35e5380844b795fd28f6dc53b282ef1d73d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 20 Mar 2025 21:32:32 -0700 Subject: [PATCH 06/10] fix: incorrect value type for `@default` with a boolean value from `auth() fixes #2038 --- .../src/plugins/prisma/schema-generator.ts | 2 +- tests/regression/tests/issue-2038.test.ts | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/regression/tests/issue-2038.test.ts diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index 8df59941c..1194099d2 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -893,7 +893,7 @@ export class PrismaSchemaGenerator { const dummyDefaultValue = match(field.type.type) .with('String', () => new AttributeArgValue('String', '')) .with(P.union('Int', 'BigInt', 'Float', 'Decimal'), () => new AttributeArgValue('Number', '0')) - .with('Boolean', () => new AttributeArgValue('Boolean', 'false')) + .with('Boolean', () => new AttributeArgValue('Boolean', false)) .with('DateTime', () => new AttributeArgValue('FunctionCall', new PrismaFunctionCall('now'))) .with('Json', () => new AttributeArgValue('String', '{}')) .with('Bytes', () => new AttributeArgValue('String', '')) diff --git a/tests/regression/tests/issue-2038.test.ts b/tests/regression/tests/issue-2038.test.ts new file mode 100644 index 000000000..02218eaab --- /dev/null +++ b/tests/regression/tests/issue-2038.test.ts @@ -0,0 +1,26 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 2038', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + flag Boolean + @@allow('all', true) + } + + model Post { + id Int @id @default(autoincrement()) + published Boolean @default(auth().flag) + @@allow('all', true) + } + ` + ); + + const db = enhance({ id: 1, flag: true }); + await expect(db.post.create({ data: {} })).resolves.toMatchObject({ + published: true, + }); + }); +}); From e0676f2563a7f3489a1419906d8b3d3c0ced89b7 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 20 Mar 2025 22:11:46 -0700 Subject: [PATCH 07/10] fix: incorrect value type when using `@default` with boolean field from `auth()` (#2052) From 8a1289c2c37a877ed9a5667a2f965dd9506e32c0 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Sun, 23 Mar 2025 13:19:47 -0700 Subject: [PATCH 08/10] fix(policy): revers lookup condition to parent entity is not properly built with compound id fields (#2053) --- .../runtime/src/enhancements/node/delegate.ts | 10 +- .../src/enhancements/node/policy/handler.ts | 29 ++-- .../enhancements/node/policy/policy-utils.ts | 3 - .../src/enhancements/node/query-utils.ts | 39 +++++- packages/server/src/api/rest/index.ts | 2 +- packages/testtools/src/schema.ts | 6 +- .../with-policy/unique-as-id.test.ts | 123 +++++++++++++++++ tests/regression/tests/issue-1964.test.ts | 128 ++++++++++++++++++ tests/regression/tests/issue-765.test.ts | 5 +- 9 files changed, 316 insertions(+), 29 deletions(-) create mode 100644 tests/regression/tests/issue-1964.test.ts diff --git a/packages/runtime/src/enhancements/node/delegate.ts b/packages/runtime/src/enhancements/node/delegate.ts index 59bc79793..54c1abf96 100644 --- a/packages/runtime/src/enhancements/node/delegate.ts +++ b/packages/runtime/src/enhancements/node/delegate.ts @@ -902,7 +902,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { } else { // translate to plain `update` for nested write into base fields const findArgs = { - where: clone(args.where), + where: clone(args.where ?? {}), select: this.queryUtils.makeIdSelection(model), }; await this.injectUpdateHierarchy(db, model, findArgs); @@ -959,7 +959,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { this.injectWhereHierarchy(model, (args as any)?.where); this.doProcessUpdatePayload(model, (args as any)?.data); } else { - const where = this.queryUtils.buildReversedQuery(context, false, false); + const where = await this.queryUtils.buildReversedQuery(db, context, false, false); await this.queryUtils.transaction(db, async (tx) => { await this.doUpdateMany(tx, model, { ...args, where }, simpleUpdateMany); }); @@ -1022,7 +1022,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { }, delete: async (model, _args, context) => { - const where = this.queryUtils.buildReversedQuery(context, false, false); + const where = await this.queryUtils.buildReversedQuery(db, context, false, false); await this.queryUtils.transaction(db, async (tx) => { await this.doDelete(tx, model, { where }); }); @@ -1030,7 +1030,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { }, deleteMany: async (model, _args, context) => { - const where = this.queryUtils.buildReversedQuery(context, false, false); + const where = await this.queryUtils.buildReversedQuery(db, context, false, false); await this.queryUtils.transaction(db, async (tx) => { await this.doDeleteMany(tx, model, where); }); @@ -1095,7 +1095,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { private async doDeleteMany(db: CrudContract, model: string, where: any): Promise<{ count: number }> { // query existing entities with id const idSelection = this.queryUtils.makeIdSelection(model); - const findArgs = { where: clone(where), select: idSelection }; + const findArgs = { where: clone(where ?? {}), select: idSelection }; this.injectWhereHierarchy(model, findArgs.where); if (this.options.logPrismaQuery) { diff --git a/packages/runtime/src/enhancements/node/policy/handler.ts b/packages/runtime/src/enhancements/node/policy/handler.ts index 5c5fdd4ca..4285d3bd2 100644 --- a/packages/runtime/src/enhancements/node/policy/handler.ts +++ b/packages/runtime/src/enhancements/node/policy/handler.ts @@ -809,7 +809,7 @@ export class PolicyProxyHandler implements Pr const unsafe = isUnsafeMutate(model, args, this.modelMeta); // handles the connection to upstream entity - const reversedQuery = this.policyUtils.buildReversedQuery(context, true, unsafe); + const reversedQuery = await this.policyUtils.buildReversedQuery(db, context, true, unsafe); if ((!unsafe || context.field.isRelationOwner) && reversedQuery[context.field.backLink]) { // if mutation is safe, or current field owns the relation (so the other side has no fk), // and the reverse query contains the back link, then we can build a "connect" with it @@ -885,7 +885,7 @@ export class PolicyProxyHandler implements Pr if (args.skipDuplicates) { // get a reversed query to include fields inherited from upstream mutation, // it'll be merged with the create payload for unique constraint checking - const upstreamQuery = this.policyUtils.buildReversedQuery(context); + const upstreamQuery = await this.policyUtils.buildReversedQuery(db, context); if (await this.hasDuplicatedUniqueConstraint(model, item, upstreamQuery, db)) { if (this.shouldLogQuery) { this.logger.info(`[policy] \`createMany\` skipping duplicate ${formatObject(item)}`); @@ -910,7 +910,7 @@ export class PolicyProxyHandler implements Pr if (operation === 'disconnect') { // disconnect filter is not unique, need to build a reversed query to // locate the entity and use its id fields as unique filter - const reversedQuery = this.policyUtils.buildReversedQuery(context); + const reversedQuery = await this.policyUtils.buildReversedQuery(db, context); const found = await db[model].findUnique({ where: reversedQuery, select: this.policyUtils.makeIdSelection(model), @@ -936,7 +936,7 @@ export class PolicyProxyHandler implements Pr const visitor = new NestedWriteVisitor(this.modelMeta, { update: async (model, args, context) => { // build a unique query including upstream conditions - const uniqueFilter = this.policyUtils.buildReversedQuery(context); + const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context); // handle not-found const existing = await this.policyUtils.checkExistence(db, model, uniqueFilter, true); @@ -997,7 +997,7 @@ export class PolicyProxyHandler implements Pr if (preValueSelect) { select = { ...select, ...preValueSelect }; } - const reversedQuery = this.policyUtils.buildReversedQuery(context); + const reversedQuery = await this.policyUtils.buildReversedQuery(db, context); const currentSetQuery = { select, where: reversedQuery }; this.policyUtils.injectAuthGuardAsWhere(db, currentSetQuery, model, 'read'); @@ -1027,7 +1027,7 @@ export class PolicyProxyHandler implements Pr } 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.policyUtils.buildReversedQuery(context); + const reversedQuery = await this.policyUtils.buildReversedQuery(db, context); const updateWhere = this.policyUtils.and(reversedQuery, updateGuard); if (this.shouldLogQuery) { this.logger.info( @@ -1066,7 +1066,7 @@ export class PolicyProxyHandler implements Pr upsert: async (model, args, context) => { // build a unique query including upstream conditions - const uniqueFilter = this.policyUtils.buildReversedQuery(context); + const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context); // branch based on if the update target exists const existing = await this.policyUtils.checkExistence(db, model, uniqueFilter); @@ -1090,7 +1090,7 @@ export class PolicyProxyHandler implements Pr // convert upsert to update const convertedUpdate = { - where: args.where, + where: args.where ?? {}, data: this.validateUpdateInputSchema(model, args.update), }; this.mergeToParent(context.parent, 'update', convertedUpdate); @@ -1143,7 +1143,7 @@ export class PolicyProxyHandler implements Pr set: async (model, args, context) => { // find the set of items to be replaced - const reversedQuery = this.policyUtils.buildReversedQuery(context); + const reversedQuery = await this.policyUtils.buildReversedQuery(db, context); const findCurrSetArgs = { select: this.policyUtils.makeIdSelection(model), where: reversedQuery, @@ -1162,7 +1162,7 @@ export class PolicyProxyHandler implements Pr delete: async (model, args, context) => { // build a unique query including upstream conditions - const uniqueFilter = this.policyUtils.buildReversedQuery(context); + const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context); // handle not-found await this.policyUtils.checkExistence(db, model, uniqueFilter, true); @@ -1179,7 +1179,7 @@ export class PolicyProxyHandler implements Pr } 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.policyUtils.buildReversedQuery(context); + const reversedQuery = await this.policyUtils.buildReversedQuery(db, context); const deleteWhere = this.policyUtils.and(reversedQuery, guard); if (this.shouldLogQuery) { this.logger.info(`[policy] \`deleteMany\` ${model}:\n${formatObject({ where: deleteWhere })}`); @@ -1579,12 +1579,15 @@ export class PolicyProxyHandler implements Pr if (this.shouldLogQuery) { this.logger.info( `[policy] \`findMany\` ${this.model}: ${formatObject({ - where: args.where, + where: args.where ?? {}, select: candidateSelect, })}` ); } - const candidates = await tx[this.model].findMany({ where: args.where, select: candidateSelect }); + const candidates = await tx[this.model].findMany({ + where: args.where ?? {}, + select: candidateSelect, + }); // build a ID filter based on id values filtered by the additional checker const { idFilter } = this.buildIdFilterWithEntityChecker(candidates, entityChecker.func); diff --git a/packages/runtime/src/enhancements/node/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts index ef4285d78..0e655a3e5 100644 --- a/packages/runtime/src/enhancements/node/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -30,7 +30,6 @@ import { } from '../../../types'; import { getVersion } from '../../../version'; import type { InternalEnhancementOptions } from '../create-enhancement'; -import { Logger } from '../logger'; import { QueryUtils } from '../query-utils'; import type { DelegateConstraint, @@ -47,7 +46,6 @@ import { formatObject, prismaClientKnownRequestError } from '../utils'; * Access policy enforcement utilities */ export class PolicyUtil extends QueryUtils { - private readonly logger: Logger; private readonly modelMeta: ModelMeta; private readonly policy: PolicyDef; private readonly zodSchemas?: ZodSchemas; @@ -62,7 +60,6 @@ export class PolicyUtil extends QueryUtils { ) { super(db, options); - this.logger = new Logger(db); this.user = context?.user; ({ diff --git a/packages/runtime/src/enhancements/node/query-utils.ts b/packages/runtime/src/enhancements/node/query-utils.ts index 75e729b0f..58cd0098d 100644 --- a/packages/runtime/src/enhancements/node/query-utils.ts +++ b/packages/runtime/src/enhancements/node/query-utils.ts @@ -10,11 +10,17 @@ import { } from '../../cross'; import type { CrudContract, DbClientContract } from '../../types'; import { getVersion } from '../../version'; +import { formatObject } from '../edge'; import { InternalEnhancementOptions } from './create-enhancement'; +import { Logger } from './logger'; import { prismaClientUnknownRequestError, prismaClientValidationError } from './utils'; export class QueryUtils { - constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) {} + protected readonly logger: Logger; + + constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) { + this.logger = new Logger(prisma); + } getIdFields(model: string) { return getIdFields(this.options.modelMeta, model, true); @@ -60,7 +66,12 @@ export class QueryUtils { /** * Builds a reversed query for the given nested path. */ - buildReversedQuery(context: NestedWriteVisitorContext, forMutationPayload = false, unsafeOperation = false) { + async buildReversedQuery( + db: CrudContract, + context: NestedWriteVisitorContext, + forMutationPayload = false, + uncheckedOperation = false + ) { let result, currQuery: any; let currField: FieldInfo | undefined; @@ -102,8 +113,8 @@ export class QueryUtils { const shouldPreserveRelationCondition = // doing a mutation forMutationPayload && - // and it's a safe mutate - !unsafeOperation && + // and it's not an unchecked mutate + !uncheckedOperation && // and the current segment is the direct parent (the last one is the mutate itself), // the relation condition should be preserved and will be converted to a "connect" later i === context.nestingPath.length - 2; @@ -111,8 +122,26 @@ export class QueryUtils { if (fkMapping && !shouldPreserveRelationCondition) { // turn relation condition into foreign key condition, e.g.: // { user: { id: 1 } } => { userId: 1 } + + let parentPk = visitWhere; + if (Object.keys(fkMapping).some((k) => !(k in parentPk) || parentPk[k] === undefined)) { + // it can happen that the parent condition actually doesn't contain all id fields + // (when the parent condition is not a primary key but unique constraints) + // and in such case we need to load it to get the pks + + if (this.options.logPrismaQuery && this.logger.enabled('info')) { + this.logger.info( + `[reverseLookup] \`findUniqueOrThrow\` ${model}: ${formatObject(where)}` + ); + } + parentPk = await db[model].findUniqueOrThrow({ + where, + select: this.makeIdSelection(model), + }); + } + for (const [r, fk] of Object.entries(fkMapping)) { - currQuery[fk] = visitWhere[r]; + currQuery[fk] = parentPk[r]; } if (i > 0) { diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 16de93637..c50e5aa5b 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -678,7 +678,7 @@ class RequestHandler extends APIHandlerBase { args.take = limit; const [entities, count] = await Promise.all([ prisma[type].findMany(args), - prisma[type].count({ where: args.where }), + prisma[type].count({ where: args.where ?? {} }), ]); const total = count as number; diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 4a7b575bd..51814a3e7 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -96,7 +96,11 @@ datasource db { generator js { provider = 'prisma-client-js' - ${options.previewFeatures ? `previewFeatures = ${JSON.stringify(options.previewFeatures)}` : ''} + ${ + options.previewFeatures + ? `previewFeatures = ${JSON.stringify(options.previewFeatures)}` + : 'previewFeatures = ["strictUndefinedChecks"]' + } } plugin enhancer { diff --git a/tests/integration/tests/enhancements/with-policy/unique-as-id.test.ts b/tests/integration/tests/enhancements/with-policy/unique-as-id.test.ts index a7ec74fa5..3f4de2fd1 100644 --- a/tests/integration/tests/enhancements/with-policy/unique-as-id.test.ts +++ b/tests/integration/tests/enhancements/with-policy/unique-as-id.test.ts @@ -171,4 +171,127 @@ describe('With Policy: unique as id', () => { }) ).toResolveTruthy(); }); + + it('unique fields with to-many nested update', async () => { + const { enhance } = await loadSchema( + ` + model A { + id Int @id @default(autoincrement()) + x Int + y Int + value Int + bs B[] + @@unique([x, y]) + + @@allow('read,create', true) + @@allow('update,delete', value > 0) + } + + model B { + id Int @id @default(autoincrement()) + value Int + a A @relation(fields: [aId], references: [id]) + aId Int + + @@allow('all', value > 0) + } + `, + { logPrismaQuery: true } + ); + + const db = enhance(); + + await db.a.create({ + data: { x: 1, y: 1, value: 1, bs: { create: [{ id: 1, value: 1 }] } }, + }); + + await db.a.create({ + data: { x: 2, y: 2, value: 2, bs: { create: [{ id: 2, value: 2 }] } }, + }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { bs: { updateMany: { data: { value: 3 } } } }, + }); + + // check b#1 is updated + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ value: 3 }); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { bs: { deleteMany: {} } }, + }); + + // check b#1 is deleted + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toBeNull(); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + }); + + it('unique fields with to-one nested update', async () => { + const { enhance } = await loadSchema( + ` + model A { + id Int @id @default(autoincrement()) + x Int + y Int + value Int + b B? + @@unique([x, y]) + + @@allow('read,create', true) + @@allow('update,delete', value > 0) + } + + model B { + id Int @id @default(autoincrement()) + value Int + a A @relation(fields: [aId], references: [id]) + aId Int @unique + + @@allow('all', value > 0) + } + ` + ); + + const db = enhance(); + + await db.a.create({ + data: { x: 1, y: 1, value: 1, b: { create: { id: 1, value: 1 } } }, + }); + + await db.a.create({ + data: { x: 2, y: 2, value: 2, b: { create: { id: 2, value: 2 } } }, + }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { b: { update: { data: { value: 3 } } } }, + }); + + // check b#1 is updated + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ value: 3 }); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + + await db.a.update({ + where: { x_y: { x: 1, y: 1 } }, + data: { b: { delete: true } }, + }); + + // check b#1 is deleted + await expect(db.b.findUnique({ where: { id: 1 } })).resolves.toBeNull(); + await expect(db.a.findUnique({ where: { x_y: { x: 1, y: 1 } }, include: { b: true } })).resolves.toMatchObject({ + b: null, + }); + + // check b#2 is not affected + await expect(db.b.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ value: 2 }); + await expect(db.a.findUnique({ where: { x_y: { x: 2, y: 2 } }, include: { b: true } })).resolves.toBeTruthy(); + }); }); diff --git a/tests/regression/tests/issue-1964.test.ts b/tests/regression/tests/issue-1964.test.ts new file mode 100644 index 000000000..e90aae32e --- /dev/null +++ b/tests/regression/tests/issue-1964.test.ts @@ -0,0 +1,128 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1964', () => { + it('regression1', async () => { + const { enhance } = await loadSchema( + ` +model User { + id Int @id + orgId String +} + +model Author { + id Int @id @default(autoincrement()) + orgId String + name String + posts Post[] + + @@unique([orgId, name]) + @@allow('all', auth().orgId == orgId) +} + +model Post { + id Int @id @default(autoincrement()) + orgId String + title String + author Author @relation(fields: [authorId], references: [id]) + authorId Int + + @@allow('all', auth().orgId == orgId) +} + `, + { + previewFeatures: ['strictUndefinedChecks'], + } + ); + + const db = enhance({ id: 1, orgId: 'org' }); + + const newauthor = await db.author.create({ + data: { + name: `Foo ${Date.now()}`, + orgId: 'org', + posts: { + createMany: { data: [{ title: 'Hello', orgId: 'org' }] }, + }, + }, + include: { posts: true }, + }); + + await expect( + db.author.update({ + where: { orgId_name: { orgId: 'org', name: newauthor.name } }, + data: { + name: `Bar ${Date.now()}`, + posts: { deleteMany: { id: { equals: newauthor.posts[0].id } } }, + }, + }) + ).toResolveTruthy(); + }); + + it('regression2', async () => { + const { enhance } = await loadSchema( + ` +model User { + id Int @id @default(autoincrement()) + slug String @unique + profile Profile? + @@allow('all', true) +} + +model Profile { + id Int @id @default(autoincrement()) + slug String @unique + name String + addresses Address[] + userId Int? @unique + user User? @relation(fields: [userId], references: [id]) + @@allow('all', true) +} + +model Address { + id Int @id @default(autoincrement()) + profileId Int @unique + profile Profile @relation(fields: [profileId], references: [id]) + city String + @@allow('all', true) +} + `, + { + previewFeatures: ['strictUndefinedChecks'], + } + ); + + const db = enhance({ id: 1, orgId: 'org' }); + + await db.user.create({ + data: { + slug: `user1`, + profile: { + create: { + name: `My Profile`, + slug: 'profile1', + addresses: { + create: { id: 1, city: 'City' }, + }, + }, + }, + }, + }); + + await expect( + db.user.update({ + where: { slug: 'user1' }, + data: { + profile: { + update: { + addresses: { + deleteMany: { id: { equals: 1 } }, + }, + }, + }, + }, + }) + ).toResolveTruthy(); + + await expect(db.address.count()).resolves.toEqual(0); + }); +}); diff --git a/tests/regression/tests/issue-765.test.ts b/tests/regression/tests/issue-765.test.ts index f29ef1a5a..13f88bf19 100644 --- a/tests/regression/tests/issue-765.test.ts +++ b/tests/regression/tests/issue-765.test.ts @@ -21,7 +21,10 @@ describe('Regression: issue 765', () => { @@allow('all', true) } - ` + `, + { + previewFeatures: [], + } ); const db = enhance(); From 5224d56bcca27b97efee92a46c4e03a4bf0f8142 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Sun, 23 Mar 2025 19:46:23 -0700 Subject: [PATCH 09/10] chore: bump version (#2055) --- package.json | 2 +- packages/ide/jetbrains/build.gradle.kts | 2 +- packages/ide/jetbrains/package.json | 2 +- packages/language/package.json | 2 +- packages/misc/redwood/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 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 35dd78b01..39b0cea4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.12.3", + "version": "2.13.0", "description": "", "scripts": { "build": "pnpm -r --filter=\"!./packages/ide/*\" build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 833ce3049..83c4b197c 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "2.12.3" +version = "2.13.0" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index 113efe799..2be7eaad2 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.12.3", + "version": "2.13.0", "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 0aa64267a..a195b8a6b 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.12.3", + "version": "2.13.0", "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 562e30da8..8b3c9a8ad 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": "2.12.3", + "version": "2.13.0", "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 eeccaba91..a6d582069 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": "2.12.3", + "version": "2.13.0", "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 cabc1f49d..008d43026 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": "2.12.3", + "version": "2.13.0", "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 6518ca605..502acdab5 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": "2.12.3", + "version": "2.13.0", "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 713db6b85..cf1303e58 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": "2.12.3", + "version": "2.13.0", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index cc18fbd65..20ad561c9 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.12.3", + "version": "2.13.0", "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 877b2751a..668ae0f96 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI", - "version": "2.12.3", + "version": "2.13.0", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index dfb6ab009..eb50c1d6c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.12.3", + "version": "2.13.0", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index fb39c12be..06fda5ad8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.12.3", + "version": "2.13.0", "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 c3391dd05..779b505c7 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.12.3", + "version": "2.13.0", "description": "ZenStack Test Tools", "main": "index.js", "private": true, From 718fd677783b8c0c8ebbda0cab9f4fde1d880c0b Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Sun, 23 Mar 2025 20:21:57 -0700 Subject: [PATCH 10/10] chore: more tests about delegate models for updateMany (#2056) --- .../with-delegate/enhanced-client.test.ts | 48 ++++++++++++++++--- .../tests/enhancements/with-delegate/utils.ts | 1 + 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts b/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts index 7a555e0cd..1f7a40129 100644 --- a/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts +++ b/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts @@ -591,24 +591,58 @@ describe('Polymorphism Test', () => { }); it('update nested updateMany', async () => { - const { db, videoWithOwner: video, user } = await setup(); + const { db } = await setup(); - // updateMany - await db.user.update({ - where: { id: user.id }, + const user = await db.user.create({ data: { + email: 'a@b.com', ratedVideos: { - create: { url: 'xyz', duration: 111, rating: 222, owner: { connect: { id: user.id } } }, + create: { id: 10, url: 'xyz', duration: 1, rating: 111 }, }, }, }); + + // create another user and video + await db.user.create({ + data: { + email: 'b@c.com', + ratedVideos: { + create: { id: 20, url: 'abc', duration: 2, rating: 222 }, + }, + }, + }); + + // updateMany with filter await expect( db.user.update({ where: { id: user.id }, - data: { ratedVideos: { updateMany: { where: { duration: 111 }, data: { rating: 333 } } } }, + data: { + ratedVideos: { updateMany: { where: { duration: 1 }, data: { rating: 333 } } }, + }, include: { ratedVideos: true }, }) - ).resolves.toMatchObject({ ratedVideos: expect.arrayContaining([expect.objectContaining({ rating: 333 })]) }); + ).resolves.toMatchObject({ + ratedVideos: expect.arrayContaining([expect.objectContaining({ rating: 333 })]), + }); + + // updateMany without filter + await expect( + db.user.update({ + where: { email: 'a@b.com' }, + data: { + ratedVideos: { updateMany: { data: { duration: 3 } } }, + }, + include: { ratedVideos: true }, + }) + ).resolves.toMatchObject({ + ratedVideos: expect.arrayContaining([expect.objectContaining({ duration: 3 })]), + }); + + // user2's video should not be updated + await expect(db.ratedVideo.findUnique({ where: { id: 20 } })).resolves.toMatchObject({ + duration: 2, + rating: 222, + }); }); it('update nested deleteOne', async () => { diff --git a/tests/integration/tests/enhancements/with-delegate/utils.ts b/tests/integration/tests/enhancements/with-delegate/utils.ts index 66f29b221..41700bd4b 100644 --- a/tests/integration/tests/enhancements/with-delegate/utils.ts +++ b/tests/integration/tests/enhancements/with-delegate/utils.ts @@ -1,6 +1,7 @@ export const POLYMORPHIC_SCHEMA = ` model User { id Int @id @default(autoincrement()) + email String? @unique level Int @default(0) assets Asset[] ratedVideos RatedVideo[] @relation('direct')