diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 43884f6b2..f2c5769a6 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -573,13 +573,19 @@ export abstract class BaseOperationHandler { thisCreateFields[discriminatorField] = forModel; // create base model entity - const createResult = await this.create(kysely, model as GetModels, thisCreateFields, undefined, true); + const baseEntity: any = await this.create( + kysely, + model as GetModels, + thisCreateFields, + undefined, + true, + ); // copy over id fields from base model - const idValues = extractIdFields(createResult, this.schema, model); + const idValues = extractIdFields(baseEntity, this.schema, model); Object.assign(remainingFields, idValues); - return { baseEntity: createResult, remainingFields }; + return { baseEntity, remainingFields }; } private buildFkAssignments(model: string, relationField: string, entity: any) { @@ -844,7 +850,7 @@ export abstract class BaseOperationHandler { relationKeyPairs = keyPairs; } - const createData = enumerate(input.data).map((item) => { + let createData = enumerate(input.data).map((item) => { const newItem: any = {}; for (const [name, value] of Object.entries(item)) { const fieldDef = this.requireField(model, name); @@ -859,6 +865,22 @@ export abstract class BaseOperationHandler { return this.fillGeneratedValues(modelDef, newItem); }); + if (modelDef.baseModel) { + if (input.skipDuplicates) { + // TODO: simulate createMany with create in this case + throw new QueryError('"skipDuplicates" options is not supported for polymorphic models'); + } + // create base hierarchy + const baseCreateResult = await this.processBaseModelCreateMany( + kysely, + modelDef.baseModel, + createData, + !!input.skipDuplicates, + model, + ); + createData = baseCreateResult.remainingFieldRows; + } + const query = kysely .insertInto(model) .values(createData) @@ -880,6 +902,50 @@ export abstract class BaseOperationHandler { } } + private async processBaseModelCreateMany( + kysely: ToKysely, + model: string, + createRows: any[], + skipDuplicates: boolean, + forModel: GetModels, + ) { + const thisCreateRows: any[] = []; + const remainingFieldRows: any[] = []; + const discriminatorField = getDiscriminatorField(this.schema, model); + invariant(discriminatorField, `Base model "${model}" must have a discriminator field`); + + for (const createFields of createRows) { + const thisCreateFields: any = {}; + const remainingFields: any = {}; + Object.entries(createFields).forEach(([field, value]) => { + const fieldDef = this.getField(model, field); + if (fieldDef) { + thisCreateFields[field] = value; + } else { + remainingFields[field] = value; + } + }); + thisCreateFields[discriminatorField] = forModel; + thisCreateRows.push(thisCreateFields); + remainingFieldRows.push(remainingFields); + } + + // create base model entity + const baseEntities = await this.createMany( + kysely, + model as GetModels, + { data: thisCreateRows, skipDuplicates }, + true, + ); + + // copy over id fields from base model + for (let i = 0; i < baseEntities.length; i++) { + const idValues = extractIdFields(baseEntities[i], this.schema, model); + Object.assign(remainingFieldRows[i], idValues); + } + return { baseEntities, remainingFieldRows }; + } + private fillGeneratedValues(modelDef: ModelDef, data: object) { const fields = modelDef.fields; const values: any = clone(data); @@ -947,7 +1013,7 @@ export abstract class BaseOperationHandler { fromRelation?: FromRelationContext, allowRelationUpdate = true, throwIfNotFound = true, - ) { + ): Promise { if (!data || typeof data !== 'object') { throw new InternalError('data must be an object'); } @@ -1004,14 +1070,41 @@ export abstract class BaseOperationHandler { } if (Object.keys(finalData).length === 0) { - // update without data, simply return - const r = await this.readUnique(kysely, model, { + // nothing to update, return the original filter so that caller can identify the entity + return combinedWhere; + } + + let needIdRead = false; + if (modelDef.baseModel && !this.isIdFilter(model, combinedWhere)) { + // when updating a model with delegate base, base fields may be referenced in the filter, + // so we read the id out if the filter is not ready an id filter, and and use it as the + // update filter instead + needIdRead = true; + } + + if (needIdRead) { + const readResult = await this.readUnique(kysely, model, { where: combinedWhere, - } as FindArgs, true>); - if (!r && throwIfNotFound) { + select: this.makeIdSelect(model), + }); + if (!readResult && throwIfNotFound) { throw new NotFoundError(model); } - return r; + combinedWhere = readResult; + } + + if (modelDef.baseModel) { + const baseUpdateResult = await this.processBaseModelUpdate( + kysely, + modelDef.baseModel, + combinedWhere, + finalData, + throwIfNotFound, + ); + // only fields not consumed by base update will be used for this model + finalData = baseUpdateResult.remainingFields; + // base update may change entity ids, update the filter + combinedWhere = baseUpdateResult.baseEntity; } const updateFields: any = {}; @@ -1020,28 +1113,7 @@ export abstract class BaseOperationHandler { for (const field in finalData) { const fieldDef = this.requireField(model, field); if (isScalarField(this.schema, model, field) || isForeignKeyField(this.schema, model, field)) { - if (this.isNumericField(fieldDef) && typeof finalData[field] === 'object' && finalData[field]) { - // numeric fields incremental updates - updateFields[field] = this.transformIncrementalUpdate(model, field, fieldDef, finalData[field]); - continue; - } - - if ( - fieldDef.array && - typeof finalData[field] === 'object' && - !Array.isArray(finalData[field]) && - finalData[field] - ) { - // scalar list updates - updateFields[field] = this.transformScalarListUpdate(model, field, fieldDef, finalData[field]); - continue; - } - - updateFields[field] = this.dialect.transformPrimitive( - finalData[field], - fieldDef.type as BuiltinType, - !!fieldDef.array, - ); + updateFields[field] = this.processScalarFieldUpdateData(model, field, finalData); } else { if (!allowRelationUpdate) { throw new QueryError(`Relation update not allowed for field "${field}"`); @@ -1072,8 +1144,8 @@ export abstract class BaseOperationHandler { } if (Object.keys(updateFields).length === 0) { - // nothing to update, simply read back - return thisEntity ?? (await this.readUnique(kysely, model, { where: combinedWhere })); + // nothing to update, return the filter so that the caller can identify the entity + return combinedWhere; } else { const idFields = getIdFields(this.schema, model); const query = kysely @@ -1111,6 +1183,71 @@ export abstract class BaseOperationHandler { } } + private processScalarFieldUpdateData(model: GetModels, field: string, data: any): any { + const fieldDef = this.requireField(model, field); + if (this.isNumericIncrementalUpdate(fieldDef, data[field])) { + // numeric fields incremental updates + return this.transformIncrementalUpdate(model, field, fieldDef, data[field]); + } + + if (fieldDef.array && typeof data[field] === 'object' && !Array.isArray(data[field]) && data[field]) { + // scalar list updates + return this.transformScalarListUpdate(model, field, fieldDef, data[field]); + } + + return this.dialect.transformPrimitive(data[field], fieldDef.type as BuiltinType, !!fieldDef.array); + } + + private isNumericIncrementalUpdate(fieldDef: FieldDef, value: any) { + if (!this.isNumericField(fieldDef)) { + return false; + } + if (typeof value !== 'object' || !value) { + return false; + } + return ['increment', 'decrement', 'multiply', 'divide', 'set'].some((key) => key in value); + } + + private isIdFilter(model: GetModels, filter: any) { + if (!filter || typeof filter !== 'object') { + return false; + } + const idFields = getIdFields(this.schema, model); + return idFields.length === Object.keys(filter).length && idFields.every((field) => field in filter); + } + + private async processBaseModelUpdate( + kysely: ToKysely, + model: string, + where: any, + updateFields: any, + throwIfNotFound: boolean, + ) { + const thisUpdateFields: any = {}; + const remainingFields: any = {}; + + Object.entries(updateFields).forEach(([field, value]) => { + const fieldDef = this.getField(model, field); + if (fieldDef) { + thisUpdateFields[field] = value; + } else { + remainingFields[field] = value; + } + }); + + // update base model entity + const baseEntity: any = await this.update( + kysely, + model as GetModels, + where, + thisUpdateFields, + undefined, + undefined, + throwIfNotFound, + ); + return { baseEntity, remainingFields }; + } + private transformIncrementalUpdate( model: GetModels, field: string, @@ -1178,6 +1315,7 @@ export abstract class BaseOperationHandler { data: any, limit: number | undefined, returnData: ReturnData, + filterModel?: GetModels, ): Promise { if (typeof data !== 'object') { throw new InternalError('data must be an object'); @@ -1187,43 +1325,74 @@ export abstract class BaseOperationHandler { return (returnData ? [] : { count: 0 }) as Result; } - const updateFields: any = {}; + filterModel ??= model; + let updateFields: any = {}; for (const field in data) { - const fieldDef = this.requireField(model, field); if (isRelationField(this.schema, model, field)) { continue; } - updateFields[field] = this.dialect.transformPrimitive( - data[field], - fieldDef.type as BuiltinType, - !!fieldDef.array, + updateFields[field] = this.processScalarFieldUpdateData(model, field, data); + } + + const modelDef = this.requireModel(model); + let shouldFallbackToIdFilter = false; + + if (limit !== undefined && !this.dialect.supportsUpdateWithLimit) { + // if the dialect doesn't support update with limit natively, we'll + // simulate it by filtering by id with a limit + shouldFallbackToIdFilter = true; + } + + if (modelDef.isDelegate || modelDef.baseModel) { + // if the model is in a delegate hierarchy, we'll need to filter by + // id because the filter may involve fields in different models in + // the hierarchy + shouldFallbackToIdFilter = true; + } + + let resultFromBaseModel: any = undefined; + if (modelDef.baseModel) { + const baseResult = await this.processBaseModelUpdateMany( + kysely, + modelDef.baseModel, + where, + updateFields, + limit, + filterModel, ); + updateFields = baseResult.remainingFields; + resultFromBaseModel = baseResult.baseResult; + } + + // check again if we don't have anything to update for this model + if (Object.keys(updateFields).length === 0) { + // return result from base model if it exists, otherwise return empty result + return resultFromBaseModel ?? ((returnData ? [] : { count: 0 }) as Result); } let query = kysely.updateTable(model).set(updateFields); - if (limit === undefined) { - query = query.where((eb) => this.dialect.buildFilter(eb, model, model, where)); + if (!shouldFallbackToIdFilter) { + // simple filter + query = query + .where((eb) => this.dialect.buildFilter(eb, model, model, where)) + .$if(limit !== undefined, (qb) => qb.limit(limit!)); } else { - if (this.dialect.supportsUpdateWithLimit) { - query = query.where((eb) => this.dialect.buildFilter(eb, model, model, where)).limit(limit!); - } else { - query = query.where((eb) => - eb( - eb.refTuple( - // @ts-expect-error - ...this.buildIdFieldRefs(kysely, model), - ), - 'in', - kysely - .selectFrom(model) - .where((eb) => this.dialect.buildFilter(eb, model, model, where)) - .select(this.buildIdFieldRefs(kysely, model)) - .limit(limit!), + query = query.where((eb) => + eb( + eb.refTuple( + // @ts-expect-error + ...this.buildIdFieldRefs(kysely, model), ), - ); - } + 'in', + this.dialect + .buildSelectModel(eb, filterModel) + .where(this.dialect.buildFilter(eb, filterModel, filterModel, where)) + .select(this.buildIdFieldRefs(kysely, filterModel)) + .$if(limit !== undefined, (qb) => qb.limit(limit!)), + ), + ); } query = query.modifyEnd(this.makeContextComment({ model, operation: 'update' })); @@ -1238,9 +1407,42 @@ export abstract class BaseOperationHandler { } } + private async processBaseModelUpdateMany( + kysely: ToKysely, + model: string, + where: any, + updateFields: any, + limit: number | undefined, + filterModel: GetModels, + ) { + const thisUpdateFields: any = {}; + const remainingFields: any = {}; + + Object.entries(updateFields).forEach(([field, value]) => { + const fieldDef = this.getField(model, field); + if (fieldDef) { + thisUpdateFields[field] = value; + } else { + remainingFields[field] = value; + } + }); + + // update base model entity + const baseResult: any = await this.updateMany( + kysely, + model as GetModels, + where, + thisUpdateFields, + limit, + false, + filterModel, + ); + return { baseResult, remainingFields }; + } + private buildIdFieldRefs(kysely: ToKysely, model: GetModels) { const idFields = getIdFields(this.schema, model); - return idFields.map((f) => kysely.dynamic.ref(f)); + return idFields.map((f) => kysely.dynamic.ref(`${model}.${f}`)); } private async processRelationUpdates( diff --git a/packages/runtime/src/client/crud/operations/update.ts b/packages/runtime/src/client/crud/operations/update.ts index ab0c086f1..ea22c773c 100644 --- a/packages/runtime/src/client/crud/operations/update.ts +++ b/packages/runtime/src/client/crud/operations/update.ts @@ -25,28 +25,45 @@ export class UpdateOperationHandler extends BaseOperat } private async runUpdate(args: UpdateArgs>) { - const result = await this.safeTransaction(async (tx) => { - const updated = await this.update(tx, this.model, args.where, args.data); - return this.readUnique(tx, this.model, { - select: args.select, - include: args.include, - omit: args.omit, - where: getIdValues(this.schema, this.model, updated) as WhereInput, false>, - }); + const readBackResult = await this.safeTransaction(async (tx) => { + const updateResult = await this.update(tx, this.model, args.where, args.data); + // updated can be undefined if there's nothing to update, in that case we'll use the original + // filter to read back the entity + const readFilter = updateResult ?? args.where; + let readBackResult: any = undefined; + try { + readBackResult = await this.readUnique(tx, this.model, { + select: args.select, + include: args.include, + omit: args.omit, + where: readFilter as WhereInput, false>, + }); + } catch { + // commit the update even if read-back failed + } + return readBackResult; }); - if (!result && this.hasPolicyEnabled) { - throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back'); + if (!readBackResult) { + // update succeeded but result cannot be read back + if (this.hasPolicyEnabled) { + // if access policy is enabled, we assume it's due to read violation (not guaranteed though) + throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back'); + } else { + // this can happen if the entity is cascade deleted during the update, return null to + // be consistent with Prisma even though it doesn't comply with the method signature + return null; + } + } else { + return readBackResult; } - - // NOTE: update can actually return null if the entity being updated is deleted - // due to cascade when a relation is deleted during update. This doesn't comply - // with `update`'s method signature, but we'll allow it to be consistent with Prisma. - return result; } private async runUpdateMany(args: UpdateManyArgs>) { - return this.updateMany(this.kysely, this.model, args.where, args.data, args.limit, false); + // TODO: avoid using transaction for simple update + return this.safeTransaction(async (tx) => { + return this.updateMany(tx, this.model, args.where, args.data, args.limit, false); + }); } private async runUpdateManyAndReturn(args: UpdateManyAndReturnArgs> | undefined) { @@ -68,7 +85,15 @@ export class UpdateOperationHandler extends BaseOperat private async runUpsert(args: UpsertArgs>) { const result = await this.safeTransaction(async (tx) => { - let mutationResult = await this.update(tx, this.model, args.where, args.update, undefined, true, false); + let mutationResult: unknown = await this.update( + tx, + this.model, + args.where, + args.update, + undefined, + true, + false, + ); if (!mutationResult) { // non-existing, create diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts index 8c3a79069..6c8ece125 100644 --- a/packages/runtime/test/client-api/delegate.test.ts +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -37,7 +37,7 @@ model Asset { model Video extends Asset { duration Int - url String + url String @unique videoType String @@delegate(videoType) @@ -75,78 +75,144 @@ model Gallery { await client.$disconnect(); }); - it('works with create', async () => { - // delegate model cannot be created directly - await expect( - client.video.create({ - data: { - duration: 100, - url: 'abc', - videoType: 'MyVideo', - }, - }), - ).rejects.toThrow('is a delegate'); - await expect( - client.user.create({ - data: { - assets: { - create: { assetType: 'Video' }, + describe('Delegate create tests', () => { + it('works with create', async () => { + // delegate model cannot be created directly + await expect( + client.video.create({ + data: { + duration: 100, + url: 'abc', + videoType: 'MyVideo', }, - }, - }), - ).rejects.toThrow('is a delegate'); + }), + ).rejects.toThrow('is a delegate'); + await expect( + client.user.create({ + data: { + assets: { + create: { assetType: 'Video' }, + }, + }, + }), + ).rejects.toThrow('is a delegate'); - // create entity with two levels of delegation - await expect( - client.ratedVideo.create({ - data: { - duration: 100, - url: 'abc', - rating: 5, + // create entity with two levels of delegation + await expect( + client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + duration: 100, + url: 'abc', + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }); + + // create entity with relation + await expect( + client.ratedVideo.create({ + data: { + duration: 50, + url: 'bcd', + rating: 5, + user: { create: { email: 'u1@example.com' } }, + }, + include: { user: true }, + }), + ).resolves.toMatchObject({ + userId: expect.any(Number), + user: { + email: 'u1@example.com', }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - duration: 100, - url: 'abc', - rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', + }); + + // create entity with one level of delegation + await expect( + client.image.create({ + data: { + format: 'png', + gallery: { + create: {}, + }, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + format: 'png', + galleryId: expect.any(Number), + assetType: 'Image', + }); }); - // create entity with relation - await expect( - client.ratedVideo.create({ - data: { - duration: 50, - url: 'bcd', - rating: 5, - user: { create: { email: 'u1@example.com' } }, - }, - include: { user: true }, - }), - ).resolves.toMatchObject({ - userId: expect.any(Number), - user: { - email: 'u1@example.com', - }, + it('works with createMany', async () => { + await expect( + client.ratedVideo.createMany({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + }), + ).resolves.toEqual({ count: 2 }); + + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 1, + duration: 100, + url: 'abc', + rating: 5, + }), + expect.objectContaining({ + viewCount: 2, + duration: 200, + url: 'def', + rating: 4, + }), + ]), + ); + + await expect( + client.ratedVideo.createMany({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + skipDuplicates: true, + }), + ).rejects.toThrow('not supported'); }); - // create entity with one level of delegation - await expect( - client.image.create({ - data: { - format: 'png', - gallery: { - create: {}, - }, - }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - format: 'png', - galleryId: expect.any(Number), - assetType: 'Image', + it('works with createManyAndReturn', async () => { + await expect( + client.ratedVideo.createManyAndReturn({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 1, + duration: 100, + url: 'abc', + rating: 5, + }), + expect.objectContaining({ + viewCount: 2, + duration: 200, + url: 'def', + rating: 4, + }), + ]), + ); }); }); @@ -503,5 +569,336 @@ model Gallery { ).toResolveFalsy(); }); }); + + describe('Delegate update tests', async () => { + beforeEach(async () => { + const u = await client.user.create({ + data: { + id: 1, + email: 'u1@example.com', + }, + }); + await client.ratedVideo.create({ + data: { + id: 1, + viewCount: 0, + duration: 100, + url: 'v1', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + }, + }); + }); + + it('works with toplevel update', async () => { + // id filter + await expect( + client.ratedVideo.update({ + where: { id: 1 }, + data: { viewCount: { increment: 1 }, duration: 200, rating: { set: 4 } }, + }), + ).resolves.toMatchObject({ + viewCount: 1, + duration: 200, + rating: 4, + }); + await expect( + client.video.update({ + where: { id: 1 }, + data: { viewCount: { decrement: 1 }, duration: 100 }, + }), + ).resolves.toMatchObject({ + viewCount: 0, + duration: 100, + }); + await expect( + client.asset.update({ + where: { id: 1 }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toMatchObject({ + viewCount: 1, + }); + + // unique field filter + await expect( + client.ratedVideo.update({ + where: { url: 'v1' }, + data: { viewCount: 2, duration: 300, rating: 3 }, + }), + ).resolves.toMatchObject({ + viewCount: 2, + duration: 300, + rating: 3, + }); + await expect( + client.video.update({ + where: { url: 'v1' }, + data: { viewCount: 3 }, + }), + ).resolves.toMatchObject({ + viewCount: 3, + }); + + // not found + await expect( + client.ratedVideo.update({ + where: { url: 'v2' }, + data: { viewCount: 4 }, + }), + ).toBeRejectedNotFound(); + + // update id + await expect( + client.ratedVideo.update({ + where: { id: 1 }, + data: { id: 2 }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 3, + }); + }); + + it('works with nested update', async () => { + await expect( + client.user.update({ + where: { id: 1 }, + data: { + assets: { + update: { + where: { id: 1 }, + data: { viewCount: { increment: 1 } }, + }, + }, + }, + include: { assets: true }, + }), + ).resolves.toMatchObject({ + assets: [{ viewCount: 1 }], + }); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { + ratedVideos: { + update: { + where: { id: 1 }, + data: { viewCount: 2, rating: 4, duration: 200 }, + }, + }, + }, + include: { ratedVideos: true }, + }), + ).resolves.toMatchObject({ + ratedVideos: [{ viewCount: 2, rating: 4, duration: 200 }], + }); + + // unique filter + await expect( + client.user.update({ + where: { id: 1 }, + data: { + ratedVideos: { + update: { + where: { url: 'v1' }, + data: { viewCount: 3 }, + }, + }, + }, + include: { ratedVideos: true }, + }), + ).resolves.toMatchObject({ + ratedVideos: [{ viewCount: 3 }], + }); + + // deep nested + await expect( + client.user.update({ + where: { id: 1 }, + data: { + assets: { + update: { + where: { id: 1 }, + data: { comments: { create: { content: 'c1' } } }, + }, + }, + }, + include: { assets: { include: { comments: true } } }, + }), + ).resolves.toMatchObject({ + assets: [{ comments: [{ content: 'c1' }] }], + }); + }); + + it('works with updating a base relation', async () => { + await expect( + client.video.update({ + where: { id: 1 }, + data: { + owner: { update: { level: { increment: 1 } } }, + }, + include: { owner: true }, + }), + ).resolves.toMatchObject({ + owner: { level: 1 }, + }); + }); + + it('works with updateMany', async () => { + await client.ratedVideo.create({ + data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, + }); + + // update from sub model + await expect( + client.ratedVideo.updateMany({ + where: { duration: { gt: 100 } }, + data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, + }), + ).resolves.toEqual({ count: 1 }); + + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 2, + duration: 201, + rating: 3, + }), + ]), + ); + + await expect( + client.ratedVideo.updateMany({ + where: { viewCount: { gt: 1 } }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toEqual({ count: 1 }); + + await expect( + client.ratedVideo.updateMany({ + where: { rating: 3 }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toEqual({ count: 1 }); + + // update from delegate model + await expect( + client.asset.updateMany({ + where: { viewCount: { gt: 0 } }, + data: { viewCount: 100 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect( + client.video.updateMany({ + where: { duration: { gt: 200 } }, + data: { viewCount: 200, duration: 300 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 200, + duration: 300, + }), + ]), + ); + }); + + it('works with updateManyAndReturn', async () => { + await client.ratedVideo.create({ + data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, + }); + + // update from sub model + await expect( + client.ratedVideo.updateManyAndReturn({ + where: { duration: { gt: 100 } }, + data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + viewCount: 2, + duration: 201, + rating: 3, + }), + ]); + + // update from delegate model + await expect( + client.asset.updateManyAndReturn({ + where: { viewCount: { gt: 0 } }, + data: { viewCount: 100 }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + viewCount: 100, + duration: 201, + rating: 3, + }), + ]); + }); + + it('works with upsert', async () => { + await expect( + client.asset.upsert({ + where: { id: 2 }, + create: { + viewCount: 10, + assetType: 'Video', + }, + update: { + viewCount: { increment: 1 }, + }, + }), + ).rejects.toThrow('is a delegate'); + + // create case + await expect( + client.ratedVideo.upsert({ + where: { id: 2 }, + create: { + id: 2, + viewCount: 2, + duration: 200, + url: 'v2', + rating: 3, + }, + update: { + viewCount: { increment: 1 }, + }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 2, + }); + + // update case + await expect( + client.ratedVideo.upsert({ + where: { id: 2 }, + create: { + id: 2, + viewCount: 2, + duration: 200, + url: 'v2', + rating: 3, + }, + update: { + viewCount: 3, + duration: 300, + rating: 2, + }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 3, + duration: 300, + rating: 2, + }); + }); + }); }, );