From df078308699a7388afeb7f5f842f37ac0ae8b5c0 Mon Sep 17 00:00:00 2001 From: Yiming Date: Fri, 8 Mar 2024 22:11:54 -0800 Subject: [PATCH] merge from dev (#1110) Co-authored-by: ErikMCM <70036542+ErikMCM@users.noreply.github.com> Co-authored-by: Jason Kleinberg Co-authored-by: Jonathan S Co-authored-by: Jiasheng --- .../runtime/src/cross/nested-write-visitor.ts | 34 +- .../src/enhancements/policy/handler.ts | 53 ++- .../attribute-application-validator.ts | 15 +- .../validator/expression-validator.ts | 19 +- .../src/plugins/zod/utils/schema-gen.ts | 10 +- packages/schema/src/utils/ast-utils.ts | 14 + packages/schema/src/utils/pkg-utils.ts | 21 +- .../tests/generator/expression-writer.test.ts | 18 +- .../validation/attribute-validation.test.ts | 39 +-- .../validation/datamodel-validation.test.ts | 80 ++--- .../src/typescript-expression-transformer.ts | 157 +++++++-- .../with-policy/field-validation.test.ts | 331 +++++++++++++++++- .../tests/regression/issue-1078.test.ts | 55 +++ .../tests/regression/issue-1080.test.ts | 133 +++++++ 14 files changed, 829 insertions(+), 150 deletions(-) create mode 100644 tests/integration/tests/regression/issue-1078.test.ts create mode 100644 tests/integration/tests/regression/issue-1080.test.ts diff --git a/packages/runtime/src/cross/nested-write-visitor.ts b/packages/runtime/src/cross/nested-write-visitor.ts index db2455d7e..4ce4e0ae7 100644 --- a/packages/runtime/src/cross/nested-write-visitor.ts +++ b/packages/runtime/src/cross/nested-write-visitor.ts @@ -4,7 +4,7 @@ import type { FieldInfo, ModelMeta } from './model-meta'; import { resolveField } from './model-meta'; import { MaybePromise, PrismaWriteActionType, PrismaWriteActions } from './types'; -import { enumerate, getModelFields } from './utils'; +import { getModelFields } from './utils'; type NestingPathItem = { field?: FieldInfo; model: string; where: any; unique: boolean }; @@ -155,7 +155,7 @@ export class NestedWriteVisitor { // visit payload switch (action) { case 'create': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, {}); let callbackResult: any; if (this.callback.create) { @@ -183,7 +183,7 @@ export class NestedWriteVisitor { break; case 'connectOrCreate': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.connectOrCreate) { @@ -198,7 +198,7 @@ export class NestedWriteVisitor { case 'connect': if (this.callback.connect) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item, true); await this.callback.connect(model, item, newContext); } @@ -210,7 +210,7 @@ export class NestedWriteVisitor { // if relation is to-many, the payload is a unique filter object // if relation is to-one, the payload can only be boolean `true` if (this.callback.disconnect) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item, typeof item === 'object'); await this.callback.disconnect(model, item, newContext); } @@ -219,7 +219,7 @@ export class NestedWriteVisitor { case 'set': if (this.callback.set) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item, true); await this.callback.set(model, item, newContext); } @@ -227,7 +227,7 @@ export class NestedWriteVisitor { break; case 'update': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.update) { @@ -246,7 +246,7 @@ export class NestedWriteVisitor { break; case 'updateMany': - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.updateMany) { @@ -260,7 +260,7 @@ export class NestedWriteVisitor { break; case 'upsert': { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, item.where); let callbackResult: any; if (this.callback.upsert) { @@ -280,7 +280,7 @@ export class NestedWriteVisitor { case 'delete': { if (this.callback.delete) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, toplevel ? item.where : item); await this.callback.delete(model, item, newContext); } @@ -290,7 +290,7 @@ export class NestedWriteVisitor { case 'deleteMany': if (this.callback.deleteMany) { - for (const item of enumerate(data)) { + for (const item of this.enumerateReverse(data)) { const newContext = pushNewContext(field, model, toplevel ? item.where : item); await this.callback.deleteMany(model, item, newContext); } @@ -338,4 +338,16 @@ export class NestedWriteVisitor { } } } + + // enumerate a (possible) array in reverse order, so that the enumeration + // callback can safely delete the current item + private *enumerateReverse(data: any) { + if (Array.isArray(data)) { + for (let i = data.length - 1; i >= 0; i--) { + yield data[i]; + } + } else { + yield data; + } + } } diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index f2bc4ad07..6c8f6d205 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -357,17 +357,7 @@ export class PolicyProxyHandler implements Pr } } - if (context.parent.connect) { - // if the payload parent already has a "connect" clause, merge it - if (Array.isArray(context.parent.connect)) { - context.parent.connect.push(args.where); - } else { - context.parent.connect = [context.parent.connect, args.where]; - } - } else { - // otherwise, create a new "connect" clause - context.parent.connect = args.where; - } + this.mergeToParent(context.parent, 'connect', args.where); // record the key of connected entities so we can avoid validating them later connectedEntities.add(getEntityKey(model, existing)); } else { @@ -375,11 +365,11 @@ export class PolicyProxyHandler implements Pr pushIdFields(model, context); // create a new "create" clause at the parent level - context.parent.create = args.create; + this.mergeToParent(context.parent, 'create', args.create); } // remove the connectOrCreate clause - delete context.parent['connectOrCreate']; + this.removeFromParent(context.parent, 'connectOrCreate', args); // return false to prevent visiting the nested payload return false; @@ -917,7 +907,7 @@ export class PolicyProxyHandler implements Pr await _create(model, args, context); // remove it from the update payload - delete context.parent.create; + this.removeFromParent(context.parent, 'create', args); // don't visit payload return false; @@ -950,14 +940,15 @@ export class PolicyProxyHandler implements Pr await _registerPostUpdateCheck(model, uniqueFilter); // convert upsert to update - context.parent.update = { + const convertedUpdate = { where: args.where, data: this.validateUpdateInputSchema(model, args.update), }; - delete context.parent.upsert; + this.mergeToParent(context.parent, 'update', convertedUpdate); + this.removeFromParent(context.parent, 'upsert', args); // continue visiting the new payload - return context.parent.update; + return convertedUpdate; } else { // create case @@ -965,7 +956,7 @@ export class PolicyProxyHandler implements Pr await _create(model, args.create, context); // remove it from the update payload - delete context.parent.upsert; + this.removeFromParent(context.parent, 'upsert', args); // don't visit payload return false; @@ -1388,5 +1379,31 @@ export class PolicyProxyHandler implements Pr return requireField(this.modelMeta, fieldInfo.type, fieldInfo.backLink); } + private mergeToParent(parent: any, key: string, value: any) { + if (parent[key]) { + if (Array.isArray(parent[key])) { + parent[key].push(value); + } else { + parent[key] = [parent[key], value]; + } + } else { + parent[key] = value; + } + } + + private removeFromParent(parent: any, key: string, data: any) { + if (parent[key] === data) { + delete parent[key]; + } else if (Array.isArray(parent[key])) { + const idx = parent[key].indexOf(data); + if (idx >= 0) { + parent[key].splice(idx, 1); + if (parent[key].length === 0) { + delete parent[key]; + } + } + } + } + //#endregion } diff --git a/packages/schema/src/language-server/validator/attribute-application-validator.ts b/packages/schema/src/language-server/validator/attribute-application-validator.ts index f81f5c166..92c086005 100644 --- a/packages/schema/src/language-server/validator/attribute-application-validator.ts +++ b/packages/schema/src/language-server/validator/attribute-application-validator.ts @@ -15,7 +15,7 @@ import { isEnum, isReferenceExpr, } from '@zenstackhq/language/ast'; -import { isFutureExpr, isRelationshipField, resolved } from '@zenstackhq/sdk'; +import { isDataModelFieldReference, isFutureExpr, isRelationshipField, resolved } from '@zenstackhq/sdk'; import { ValidationAcceptor, streamAst } from 'langium'; import pluralize from 'pluralize'; import { AstValidator } from '../types'; @@ -151,6 +151,19 @@ export default class AttributeApplicationValidator implements AstValidator isDataModelFieldReference(node) && isDataModel(node.$resolvedType?.decl) + ) + ) { + accept('error', `\`@@validate\` condition cannot use relation fields`, { node: condition }); + } + } + private validatePolicyKinds( kind: string, candidates: string[], diff --git a/packages/schema/src/language-server/validator/expression-validator.ts b/packages/schema/src/language-server/validator/expression-validator.ts index cfc8a39af..8a87ddc14 100644 --- a/packages/schema/src/language-server/validator/expression-validator.ts +++ b/packages/schema/src/language-server/validator/expression-validator.ts @@ -1,8 +1,10 @@ import { + AstNode, BinaryExpr, Expression, ExpressionType, isDataModel, + isDataModelAttribute, isDataModelField, isEnum, isLiteralExpr, @@ -12,7 +14,7 @@ import { } from '@zenstackhq/language/ast'; import { isAuthInvocation, isDataModelFieldReference, isEnumFieldReference } from '@zenstackhq/sdk'; import { ValidationAcceptor } from 'langium'; -import { getContainingDataModel, isCollectionPredicate } from '../../utils/ast-utils'; +import { findUpAst, getContainingDataModel, isCollectionPredicate } from '../../utils/ast-utils'; import { AstValidator } from '../types'; import { typeAssignable } from './utils'; @@ -123,6 +125,17 @@ export default class ExpressionValidator implements AstValidator { case '==': case '!=': { + if (this.isInValidationContext(expr)) { + // in validation context, all fields are optional, so we should allow + // comparing any field against null + if ( + (isDataModelFieldReference(expr.left) && isNullExpr(expr.right)) || + (isDataModelFieldReference(expr.right) && isNullExpr(expr.left)) + ) { + return; + } + } + if (!!expr.left.$resolvedType?.array !== !!expr.right.$resolvedType?.array) { accept('error', 'incompatible operand types', { node: expr }); break; @@ -211,6 +224,10 @@ export default class ExpressionValidator implements AstValidator { } } + private isInValidationContext(node: AstNode) { + return findUpAst(node, (n) => isDataModelAttribute(n) && n.decl.$refText === '@@validate'); + } + private isNotModelFieldExpr(expr: Expression) { return ( isLiteralExpr(expr) || isEnumFieldReference(expr) || isNullExpr(expr) || this.isAuthOrAuthMemberAccess(expr) diff --git a/packages/schema/src/plugins/zod/utils/schema-gen.ts b/packages/schema/src/plugins/zod/utils/schema-gen.ts index ec181e8d4..b800a0869 100644 --- a/packages/schema/src/plugins/zod/utils/schema-gen.ts +++ b/packages/schema/src/plugins/zod/utils/schema-gen.ts @@ -6,6 +6,7 @@ import { getAttributeArg, getAttributeArgLiteral, getLiteral, + isDataModelFieldReference, isFromStdlib, } from '@zenstackhq/sdk'; import { @@ -203,10 +204,17 @@ export function makeValidationRefinements(model: DataModel) { const message = messageArg ? `, { message: ${JSON.stringify(messageArg)} }` : ''; try { - const expr = new TypeScriptExpressionTransformer({ + let expr = new TypeScriptExpressionTransformer({ context: ExpressionContext.ValidationRule, fieldReferenceContext: 'value', }).transform(valueArg); + + if (isDataModelFieldReference(valueArg)) { + // if the expression is a simple field reference, treat undefined + // as true since the all fields are optional in validation context + expr = `${expr} ?? true`; + } + return `.refine((value: any) => ${expr}${message})`; } catch (err) { if (err instanceof TypeScriptExpressionTransformerError) { diff --git a/packages/schema/src/utils/ast-utils.ts b/packages/schema/src/utils/ast-utils.ts index 8dfe75b4b..0f8e5567a 100644 --- a/packages/schema/src/utils/ast-utils.ts +++ b/packages/schema/src/utils/ast-utils.ts @@ -229,3 +229,17 @@ export function getRecursiveBases(dataModel: DataModel, includeDelegate = true): }); return result; } + +/** + * Walk upward from the current AST node to find the first node that satisfies the predicate. + */ +export function findUpAst(node: AstNode, predicate: (node: AstNode) => boolean): AstNode | undefined { + let curr: AstNode | undefined = node; + while (curr) { + if (predicate(curr)) { + return curr; + } + curr = curr.$container; + } + return undefined; +} diff --git a/packages/schema/src/utils/pkg-utils.ts b/packages/schema/src/utils/pkg-utils.ts index 69c42e1ae..0ac2c5379 100644 --- a/packages/schema/src/utils/pkg-utils.ts +++ b/packages/schema/src/utils/pkg-utils.ts @@ -6,18 +6,18 @@ import { match } from 'ts-pattern'; export type PackageManagers = 'npm' | 'yarn' | 'pnpm'; /** - * A type named FindUp that takes a type parameter e which extends boolean. - * If e extends true, it returns a union type of string[] or undefined. + * A type named FindUp that takes a type parameter e which extends boolean. + * If e extends true, it returns a union type of string[] or undefined. * If e does not extend true, it returns a union type of string or undefined. * * @export * @template e A type parameter that extends boolean */ -export type FindUp = e extends true ? string[] | undefined : string | undefined; +export type FindUp = e extends true ? string[] | undefined : string | undefined /** - * Find and return file paths by searching parent directories based on the given names list and current working directory (cwd) path. - * Optionally return a single path or multiple paths. - * If multiple allowed, return all paths found. + * Find and return file paths by searching parent directories based on the given names list and current working directory (cwd) path. + * Optionally return a single path or multiple paths. + * If multiple allowed, return all paths found. * If no paths are found, return undefined. * * @export @@ -28,12 +28,7 @@ export type FindUp = e extends true ? string[] | undefined : * @param [result=[]] An array of strings representing the accumulated results used in multiple results * @returns Path(s) to a specific file or folder within the directory or parent directories */ -export function findUp( - names: string[], - cwd: string = process.cwd(), - multiple: e = false as e, - result: string[] = [] -): FindUp { +export function findUp(names: string[], cwd: string = process.cwd(), multiple: e = false as e, result: string[] = []): FindUp { if (!names.some((name) => !!name)) return undefined; const target = names.find((name) => fs.existsSync(path.join(cwd, name))); if (multiple == false && target) return path.join(cwd, target) as FindUp; @@ -111,7 +106,7 @@ export function ensurePackage( } /** - * A function that searches for the nearest package.json file starting from the provided search path or the current working directory if no search path is provided. + * A function that searches for the nearest package.json file starting from the provided search path or the current working directory if no search path is provided. * It iterates through the directory structure going one level up at a time until it finds a package.json file. If no package.json file is found, it returns undefined. * @deprecated Use findUp instead @see findUp */ diff --git a/packages/schema/tests/generator/expression-writer.test.ts b/packages/schema/tests/generator/expression-writer.test.ts index a4cc6ae5f..463567d0b 100644 --- a/packages/schema/tests/generator/expression-writer.test.ts +++ b/packages/schema/tests/generator/expression-writer.test.ts @@ -1178,7 +1178,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `(user?.roles?.includes(Role.ADMIN)??false)?{AND:[]}:{OR:[]}`, + `((user?.roles?.includes(Role.ADMIN))??false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1205,7 +1205,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `(user?.email?.includes('test')??false)?{AND:[]}:{OR:[]}`, + `((user?.email?.includes('test'))??false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1218,7 +1218,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `(user?.email?.toLowerCase().includes('test'?.toLowerCase())??false)?{AND:[]}:{OR:[]}`, + `((user?.email?.toLowerCase().includes('test'?.toLowerCase()))??false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1231,7 +1231,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `(user?.email?.startsWith('test')??false)?{AND:[]}:{OR:[]}`, + `((user?.email?.startsWith('test'))??false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1244,7 +1244,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `(user?.email?.endsWith('test')??false)?{AND:[]}:{OR:[]}`, + `((user?.email?.endsWith('test'))??false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1257,7 +1257,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `(user?.roles?.includes(Role.ADMIN)??false)?{AND:[]}:{OR:[]}`, + `((user?.roles?.includes(Role.ADMIN))??false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1270,7 +1270,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `([Role.ADMIN,Role.USER]?.every((item)=>user?.roles?.includes(item))??false)?{AND:[]}:{OR:[]}`, + `((user?.roles)!==undefined?([Role.ADMIN,Role.USER]?.every((item)=>user?.roles?.includes(item))):false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1283,7 +1283,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `([Role.USER,Role.ADMIN]?.some((item)=>user?.roles?.includes(item))??false)?{AND:[]}:{OR:[]}`, + `((user?.roles)!==undefined?([Role.USER,Role.ADMIN]?.some((item)=>user?.roles?.includes(item))):false)?{AND:[]}:{OR:[]}`, userInit ); @@ -1296,7 +1296,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `((!user?.roles||user?.roles?.length===0)??false)?{AND:[]}:{OR:[]}`, + `(!user?.roles||user?.roles?.length===0)?{AND:[]}:{OR:[]}`, userInit ); }); diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts index c6d0db13b..e3c1c597e 100644 --- a/packages/schema/tests/schema/validation/attribute-validation.test.ts +++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts @@ -927,17 +927,6 @@ describe('Attribute tests', () => { @@validate(hasSome(es, [E1])) @@validate(hasEvery(es, [E1])) @@validate(isEmpty(es)) - - @@validate(n.e in [E1, E2]) - @@validate(n.i in [1, 2]) - @@validate(contains(n.s, 'a')) - @@validate(contains(n.s, 'a', true)) - @@validate(startsWith(n.s, 'a')) - @@validate(endsWith(n.s, 'a')) - @@validate(has(n.es, E1)) - @@validate(hasSome(n.es, [E1])) - @@validate(hasEvery(n.es, [E1])) - @@validate(isEmpty(n.es)) } `); @@ -1000,26 +989,21 @@ describe('Attribute tests', () => { expect( await loadModelWithError(` ${prelude} - model N { - id String @id - m M @relation(fields: [mId], references: [id]) - mId String - } model M { id String @id - n N? - @@validate(n in [1]) + x Int + @@validate(has(x, 1)) } `) - ).toContain('left operand of "in" must be of scalar type'); + ).toContain('argument is not assignable to parameter'); expect( await loadModelWithError(` ${prelude} model M { id String @id - x Int - @@validate(has(x, 1)) + x Int[] + @@validate(hasSome(x, 1)) } `) ).toContain('argument is not assignable to parameter'); @@ -1029,11 +1013,18 @@ describe('Attribute tests', () => { ${prelude} model M { id String @id - x Int[] - @@validate(hasSome(x, 1)) + n N? + @@validate(n.value > 0) + } + + model N { + id String @id + value Int + m M @relation(fields: [mId], references: [id]) + mId String @unique } `) - ).toContain('argument is not assignable to parameter'); + ).toContain('`@@validate` condition cannot use relation fields'); }); it('auth function check', async () => { diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts index 736a202cb..955f315c3 100644 --- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts +++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts @@ -10,7 +10,7 @@ describe('Data Model Validation Tests', () => { it('duplicated fields', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { id String @id x Int @@ -128,7 +128,7 @@ describe('Data Model Validation Tests', () => { it('should error when there are no unique fields', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int @@allow('all', x > 0) @@ -199,100 +199,102 @@ describe('Data Model Validation Tests', () => { x Int @@deny('all', x <= 0) } - `) - + `); + expect(result).toMatchObject(errorLike(err)); - }) + }); it('should error when there are not id fields, without access restrictions', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int @gt(0) } - `) - + `); + expect(result).toMatchObject(errorLike(err)); - }) + }); it('should error when there is more than one field marked as @id', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int @id y Int @id } - `) - expect(result).toMatchObject(errorLike(`Model can include at most one field with @id attribute`)) - }) + `); + expect(result).toMatchObject(errorLike(`Model can include at most one field with @id attribute`)); + }); - it('should error when both @id and @@id are used', async () => { + it('should error when both @id and @@id are used', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int @id y Int @@id([x, y]) } - `) - expect(result).toMatchObject(errorLike(`Model cannot have both field-level @id and model-level @@id attributes`)) - }) + `); + expect(result).toMatchObject( + errorLike(`Model cannot have both field-level @id and model-level @@id attributes`) + ); + }); it('should error when @id used on optional field', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int? @id } - `) - expect(result).toMatchObject(errorLike(`Field with @id attribute must not be optional`)) - }) + `); + expect(result).toMatchObject(errorLike(`Field with @id attribute must not be optional`)); + }); it('should error when @@id used on optional field', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int? @@id([x]) } - `) - expect(result).toMatchObject(errorLike(`Field with @id attribute must not be optional`)) - }) + `); + expect(result).toMatchObject(errorLike(`Field with @id attribute must not be optional`)); + }); it('should error when @id used on list field', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int[] @id } - `) - expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`)) - }) + `); + expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`)); + }); it('should error when @@id used on list field', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Int[] @@id([x]) } - `) - expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`)) - }) + `); + expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`)); + }); it('should error when @id used on a Json field', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Json @id } - `) - expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`)) - }) + `); + expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`)); + }); it('should error when @@id used on a Json field', async () => { const result = await safelyLoadModel(` - ${ prelude } + ${prelude} model M { x Json @@id([x]) diff --git a/packages/sdk/src/typescript-expression-transformer.ts b/packages/sdk/src/typescript-expression-transformer.ts index 20585118c..b033dab96 100644 --- a/packages/sdk/src/typescript-expression-transformer.ts +++ b/packages/sdk/src/typescript-expression-transformer.ts @@ -5,9 +5,6 @@ import { DataModel, Expression, InvocationExpr, - isDataModel, - isEnumField, - isThisExpr, LiteralExpr, MemberAccessExpr, NullExpr, @@ -16,10 +13,16 @@ import { StringLiteral, ThisExpr, UnaryExpr, + isArrayExpr, + isDataModel, + isEnumField, + isLiteralExpr, + isNullExpr, + isThisExpr, } from '@zenstackhq/language/ast'; -import { match, P } from 'ts-pattern'; +import { P, match } from 'ts-pattern'; import { ExpressionContext } from './constants'; -import { getIdFields, getLiteral, isFromStdlib, isFutureExpr } from './utils'; +import { getIdFields, getLiteral, isDataModelFieldReference, isFromStdlib, isFutureExpr } from './utils'; export class TypeScriptExpressionTransformerError extends Error { constructor(message: string) { @@ -168,13 +171,17 @@ export class TypeScriptExpressionTransformer { const max = getLiteral(args[2]); let result: string; if (min === undefined) { - result = `(${field}?.length > 0)`; + result = this.ensureBooleanTernary(args[0], field, `${field}?.length > 0`); } else if (max === undefined) { - result = `(${field}?.length >= ${min})`; + result = this.ensureBooleanTernary(args[0], field, `${field}?.length >= ${min}`); } else { - result = `(${field}?.length >= ${min} && ${field}?.length <= ${max})`; + result = this.ensureBooleanTernary( + args[0], + field, + `${field}?.length >= ${min} && ${field}?.length <= ${max}` + ); } - return this.ensureBoolean(result); + return result; } @func('contains') @@ -208,25 +215,29 @@ export class TypeScriptExpressionTransformer { private _regex(args: Expression[]) { const field = this.transform(args[0], false); const pattern = getLiteral(args[1]); - return `new RegExp(${JSON.stringify(pattern)}).test(${field})`; + return this.ensureBooleanTernary(args[0], field, `new RegExp(${JSON.stringify(pattern)}).test(${field})`); } @func('email') private _email(args: Expression[]) { const field = this.transform(args[0], false); - return `z.string().email().safeParse(${field}).success`; + return this.ensureBooleanTernary(args[0], field, `z.string().email().safeParse(${field}).success`); } @func('datetime') private _datetime(args: Expression[]) { const field = this.transform(args[0], false); - return `z.string().datetime({ offset: true }).safeParse(${field}).success`; + return this.ensureBooleanTernary( + args[0], + field, + `z.string().datetime({ offset: true }).safeParse(${field}).success` + ); } @func('url') private _url(args: Expression[]) { const field = this.transform(args[0], false); - return `z.string().url().safeParse(${field}).success`; + return this.ensureBooleanTernary(args[0], field, `z.string().url().safeParse(${field}).success`); } @func('has') @@ -239,26 +250,52 @@ export class TypeScriptExpressionTransformer { @func('hasEvery') private _hasEvery(args: Expression[], normalizeUndefined: boolean) { const field = this.transform(args[0], false); - const result = `${this.transform(args[1], normalizeUndefined)}?.every((item) => ${field}?.includes(item))`; - return this.ensureBoolean(result); + return this.ensureBooleanTernary( + args[0], + field, + `${this.transform(args[1], normalizeUndefined)}?.every((item) => ${field}?.includes(item))` + ); } @func('hasSome') private _hasSome(args: Expression[], normalizeUndefined: boolean) { const field = this.transform(args[0], false); - const result = `${this.transform(args[1], normalizeUndefined)}?.some((item) => ${field}?.includes(item))`; - return this.ensureBoolean(result); + return this.ensureBooleanTernary( + args[0], + field, + `${this.transform(args[1], normalizeUndefined)}?.some((item) => ${field}?.includes(item))` + ); } @func('isEmpty') private _isEmpty(args: Expression[]) { const field = this.transform(args[0], false); - const result = `(!${field} || ${field}?.length === 0)`; - return this.ensureBoolean(result); + return `(!${field} || ${field}?.length === 0)`; } private ensureBoolean(expr: string) { - return `(${expr} ?? false)`; + if (this.options.context === ExpressionContext.ValidationRule) { + // all fields are optional in a validation context, so we treat undefined + // as boolean true + return `(${expr} ?? true)`; + } else { + return `((${expr}) ?? false)`; + } + } + + private ensureBooleanTernary(predicate: Expression, transformedPredicate: string, value: string) { + if (isLiteralExpr(predicate) || isArrayExpr(predicate)) { + // these are never undefined + return value; + } + + if (this.options.context === ExpressionContext.ValidationRule) { + // all fields are optional in a validation context, so we treat undefined + // as boolean true + return `((${transformedPredicate}) !== undefined ? (${value}): true)`; + } else { + return `((${transformedPredicate}) !== undefined ? (${value}): false)`; + } } // #endregion @@ -300,8 +337,18 @@ export class TypeScriptExpressionTransformer { } } - private unary(expr: UnaryExpr, normalizeUndefined: boolean): string { - return `(${expr.operator} ${this.transform(expr.operand, normalizeUndefined)})`; + private unary(expr: UnaryExpr, normalizeUndefined: boolean) { + const operand = this.transform(expr.operand, normalizeUndefined); + let result = `(${expr.operator} ${operand})`; + if ( + expr.operator === '!' && + this.options.context === ExpressionContext.ValidationRule && + isDataModelFieldReference(expr.operand) + ) { + // in a validation context, we treat unary involving undefined as boolean true + result = this.ensureBooleanTernary(expr.operand, operand, result); + } + return result; } private isModelType(expr: Expression) { @@ -316,17 +363,49 @@ export class TypeScriptExpressionTransformer { left = `(${left}?.id ?? null)`; right = `(${right}?.id ?? null)`; } - const _default = `(${left} ${expr.operator} ${right})`; + + let _default = `(${left} ${expr.operator} ${right})`; + + if (this.options.context === ExpressionContext.ValidationRule) { + const nullComparison = this.extractNullComparison(expr); + if (nullComparison) { + // null comparison covers both null and undefined + const { fieldRef } = nullComparison; + const field = this.transform(fieldRef, normalizeUndefined); + if (expr.operator === '==') { + _default = `(${field} === null || ${field} === undefined)`; + } else if (expr.operator === '!=') { + _default = `(${field} !== null && ${field} !== undefined)`; + } + } else { + // for other comparisons, in a validation context, + // we treat binary involving undefined as boolean true + if (isDataModelFieldReference(expr.left)) { + _default = this.ensureBooleanTernary(expr.left, left, _default); + } + if (isDataModelFieldReference(expr.right)) { + _default = this.ensureBooleanTernary(expr.right, right, _default); + } + } + } return match(expr.operator) - .with( - 'in', - () => - `(${this.transform(expr.right, false)}?.includes(${this.transform( + .with('in', () => { + const left = `${this.transform(expr.left, normalizeUndefined)}`; + const right = `${this.transform(expr.right, false)}`; + let result = `${right}?.includes(${left})`; + if (this.options.context === ExpressionContext.ValidationRule) { + // in a validation context, we treat binary involving undefined as boolean true + result = this.ensureBooleanTernary( expr.left, - normalizeUndefined - )}) ?? false)` - ) + left, + this.ensureBooleanTernary(expr.right, right, result) + ); + } else { + result = this.ensureBoolean(result); + } + return result; + }) .with(P.union('==', '!='), () => { if (isThisExpr(expr.left) || isThisExpr(expr.right)) { // map equality comparison with `this` to id comparison @@ -352,6 +431,20 @@ export class TypeScriptExpressionTransformer { .otherwise(() => _default); } + private extractNullComparison(expr: BinaryExpr) { + if (expr.operator !== '==' && expr.operator !== '!=') { + return undefined; + } + + if (isDataModelFieldReference(expr.left) && isNullExpr(expr.right)) { + return { fieldRef: expr.left, nullExpr: expr.right }; + } else if (isDataModelFieldReference(expr.right) && isNullExpr(expr.left)) { + return { fieldRef: expr.right, nullExpr: expr.left }; + } else { + return undefined; + } + } + private collectionPredicate(expr: BinaryExpr, operator: '?' | '!' | '^', normalizeUndefined: boolean) { const operand = this.transform(expr.left, normalizeUndefined); const innerTransformer = new TypeScriptExpressionTransformer({ @@ -363,8 +456,8 @@ export class TypeScriptExpressionTransformer { const predicate = innerTransformer.transform(expr.right, normalizeUndefined); return match(operator) - .with('?', () => `!!((${operand})?.some((_item: any) => ${predicate}))`) - .with('!', () => `!!((${operand})?.every((_item: any) => ${predicate}))`) + .with('?', () => this.ensureBoolean(`(${operand})?.some((_item: any) => ${predicate})`)) + .with('!', () => this.ensureBoolean(`(${operand})?.every((_item: any) => ${predicate})`)) .with('^', () => `!((${operand})?.some((_item: any) => ${predicate}))`) .exhaustive(); } diff --git a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts index 84a3496d1..7508333b6 100644 --- a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts +++ b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts @@ -1,5 +1,5 @@ import { CrudFailureReason, isPrismaClientKnownRequestError } from '@zenstackhq/runtime'; -import { FullDbClientContract, loadSchema, run } from '@zenstackhq/testtools'; +import { FullDbClientContract, createPostgresDb, dropPostgresDb, loadSchema, run } from '@zenstackhq/testtools'; describe('With Policy: field validation', () => { let db: FullDbClientContract; @@ -609,3 +609,332 @@ describe('With Policy: field validation', () => { expect(u.tasks[0]).toMatchObject({ slug: 'slug2' }); }); }); + +describe('With Policy: model-level validation', () => { + it('create', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int + y Int + + @@validate(x > 0) + @@validate(x >= y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 0, y: 0 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 2, y: 1 } })).toResolveTruthy(); + }); + + it('update', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int + y Int + + @@validate(x >= y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 2, y: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + }); + + it('int optionality', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int? + + @@validate(x > 0) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 0 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('boolean optionality', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Boolean? + + @@validate(x) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: false } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: true } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('optionality with binary', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int? + y Int? + + @@validate(x > y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: { y: 1 } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('optionality with in operator lhs', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x String? + + @@validate(x in ['foo', 'bar']) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: 'hello' } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 'foo' } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('optionality with in operator rhs', async () => { + let prisma; + try { + const dbUrl = await createPostgresDb('field-validation-in-operator'); + const r = await loadSchema( + ` + model Model { + id Int @id @default(autoincrement()) + x String[] + + @@validate('foo' in x) + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + const db = r.enhance(); + prisma = r.prisma; + + await expect(db.model.create({ data: { x: ['hello'] } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: ['foo', 'bar'] } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + } finally { + await prisma.$disconnect(); + await dropPostgresDb('field-validation-in-operator'); + } + }); + + it('optionality with complex expression', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int? + y Int? + + @@validate(y > 1 && x > y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { y: 1 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { y: 2 } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + await expect(db.model.create({ data: { x: 1, y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: 3, y: 2 } })).toResolveTruthy(); + }); + + it('optionality with negation', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Boolean? + + @@validate(!x) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { x: true } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { x: false } })).toResolveTruthy(); + await expect(db.model.create({ data: {} })).toResolveTruthy(); + }); + + it('update implied optionality', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int + y Int + + @@validate(x > y) + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { id: 1, x: 2, y: 1 } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { y: 1 } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: {} })).toResolveTruthy(); + }); + + it('optionality with scalar functions', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + s String + e String + u String + d String + + @@validate( + length(s, 1, 5) && + contains(s, 'b') && + startsWith(s, 'a') && + endsWith(s, 'c') && + regex(s, '^[0-9a-zA-Z]*$'), + 'invalid s') + @@validate(email(e), 'invalid e') + @@validate(url(u), 'invalid u') + @@validate(datetime(d), 'invalid d') + + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect( + db.model.create({ + data: { + id: 1, + s: 'a1b2c', + e: 'a@bcd.com', + u: 'https://www.zenstack.dev', + d: '2024-01-01T00:00:00.000Z', + }, + }) + ).toResolveTruthy(); + + await expect(db.model.update({ where: { id: 1 }, data: {} })).toResolveTruthy(); + + await expect(db.model.update({ where: { id: 1 }, data: { s: 'a2b3c' } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { s: 'c2b3c' } })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { s: 'a1b2c3' } })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { s: 'aaccc' } })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { s: 'a1b2d' } })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { s: 'a1-3c' } })).toBeRejectedByPolicy(); + + await expect(db.model.update({ where: { id: 1 }, data: { e: 'b@def.com' } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { e: 'xyz' } })).toBeRejectedByPolicy(); + + await expect(db.model.update({ where: { id: 1 }, data: { u: 'https://zenstack.dev/docs' } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { u: 'xyz' } })).toBeRejectedByPolicy(); + + await expect(db.model.update({ where: { id: 1 }, data: { d: '2025-01-01T00:00:00.000Z' } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { d: 'xyz' } })).toBeRejectedByPolicy(); + }); + + it('optionality with array functions', async () => { + let prisma; + try { + const dbUrl = await createPostgresDb('field-validation-array-funcs'); + const r = await loadSchema( + ` + model Model { + id Int @id @default(autoincrement()) + x String[] + y Int[] + + @@validate( + has(x, 'a') && + hasEvery(x, ['a', 'b']) && + hasSome(x, ['x', 'y']) && + (y == null || !isEmpty(y)) + ) + + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + const db = r.enhance(); + prisma = r.prisma; + + await expect(db.model.create({ data: { id: 1, x: ['a', 'b', 'x'], y: [1] } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: {} })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { x: ['b', 'x'] } })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { x: ['a', 'b'] } })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { y: [] } })).toBeRejectedByPolicy(); + } finally { + await prisma.$disconnect(); + await dropPostgresDb('field-validation-array-funcs'); + } + }); + + it('null comparison', async () => { + const { enhance } = await loadSchema(` + model Model { + id Int @id @default(autoincrement()) + x Int + y Int + + @@validate(x == null || !(x <= 0)) + @@validate(y != null && !(y > 1)) + + @@allow('all', true) + } + `); + + const db = enhance(); + + await expect(db.model.create({ data: { id: 1, x: 1 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { id: 1, x: 1, y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { id: 1, x: 0, y: 0 } })).toBeRejectedByPolicy(); + await expect(db.model.create({ data: { id: 1, x: 1, y: 0 } })).toResolveTruthy(); + + await expect(db.model.update({ where: { id: 1 }, data: {} })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { y: 2 } })).toBeRejectedByPolicy(); + await expect(db.model.update({ where: { id: 1 }, data: { y: 1 } })).toResolveTruthy(); + await expect(db.model.update({ where: { id: 1 }, data: { x: 2, y: 1 } })).toResolveTruthy(); + }); +}); diff --git a/tests/integration/tests/regression/issue-1078.test.ts b/tests/integration/tests/regression/issue-1078.test.ts new file mode 100644 index 000000000..3c0fc7024 --- /dev/null +++ b/tests/integration/tests/regression/issue-1078.test.ts @@ -0,0 +1,55 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1078', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model Counter { + id String @id + + name String + value Int + + @@validate(value >= 0) + @@allow('all', true) + } + ` + ); + + const db = enhance(); + + await expect( + db.counter.create({ + data: { id: '1', name: 'It should create', value: 1 }, + }) + ).toResolveTruthy(); + + //! This query fails validation + await expect( + db.counter.update({ + where: { id: '1' }, + data: { name: 'It should update' }, + }) + ).toResolveTruthy(); + }); + + it('read', async () => { + const { prisma, enhance } = await loadSchema( + ` + model Post { + id Int @id() @default(autoincrement()) + title String @allow('read', true, true) + content String + } + ` + ); + + const db = enhance(); + + const post = await prisma.post.create({ data: { title: 'Post1', content: 'Content' } }); + await expect(db.post.findUnique({ where: { id: post.id } })).toResolveNull(); + await expect(db.post.findUnique({ where: { id: post.id }, select: { title: true } })).resolves.toEqual({ + title: 'Post1', + }); + }); +}); diff --git a/tests/integration/tests/regression/issue-1080.test.ts b/tests/integration/tests/regression/issue-1080.test.ts new file mode 100644 index 000000000..17ce998c2 --- /dev/null +++ b/tests/integration/tests/regression/issue-1080.test.ts @@ -0,0 +1,133 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1080', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model Project { + id String @id @unique @default(uuid()) + Fields Field[] + + @@allow('all', true) + } + + model Field { + id String @id @unique @default(uuid()) + name String + Project Project @relation(fields: [projectId], references: [id]) + projectId String + + @@allow('all', true) + } + `, + { logPrismaQuery: true } + ); + + const db = enhance(); + + const project = await db.project.create({ + include: { Fields: true }, + data: { + Fields: { + create: [{ name: 'first' }, { name: 'second' }], + }, + }, + }); + + let updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: [ + { + where: { id: project.Fields[0].id }, + create: { name: 'first1' }, + update: { name: 'first1' }, + }, + { + where: { id: project.Fields[1].id }, + create: { name: 'second1' }, + update: { name: 'second1' }, + }, + ], + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first1' }), + expect.objectContaining({ name: 'second1' }), + ]), + }); + + updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: { + where: { id: project.Fields[0].id }, + create: { name: 'first2' }, + update: { name: 'first2' }, + }, + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first2' }), + expect.objectContaining({ name: 'second1' }), + ]), + }); + + updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: { + where: { id: project.Fields[0].id }, + create: { name: 'first3' }, + update: { name: 'first3' }, + }, + update: { + where: { id: project.Fields[1].id }, + data: { name: 'second3' }, + }, + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first3' }), + expect.objectContaining({ name: 'second3' }), + ]), + }); + + updated = await db.project.update({ + where: { id: project.id }, + include: { Fields: true }, + data: { + Fields: { + upsert: { + where: { id: 'non-exist' }, + create: { name: 'third1' }, + update: { name: 'third1' }, + }, + update: { + where: { id: project.Fields[1].id }, + data: { name: 'second4' }, + }, + }, + }, + }); + expect(updated).toMatchObject({ + Fields: expect.arrayContaining([ + expect.objectContaining({ name: 'first3' }), + expect.objectContaining({ name: 'second4' }), + expect.objectContaining({ name: 'third1' }), + ]), + }); + }); +});