From b0d7f0e3b77e856c4a42056b23a2cfb1190ef035 Mon Sep 17 00:00:00 2001 From: Erik Dakoda Date: Tue, 13 Oct 2026 13:11:59 -0400 Subject: [PATCH 1/2] fix(orm): set @updatedAt fields in updateMany `update` fills `@updatedAt` fields when a scalar or foreign key field changes, but `updateMany` and `updateManyAndReturn` did not. The same gap applied to nested `updateMany` and to the base table of delegate models. Prisma and ZenStack v2 set the field in bulk updates. Move the fill step into a helper and call it from both `update` and `updateMany`. The `ignore` list and explicit values work the same way in both. `updateMany` on a delegate sub model writes the base table first and then runs the filter again for the sub model's table. A filter that reads a field changed by the base write, such as the filled `@updatedAt`, then matched no rows. The sub model now reads the ids of the matching rows first and updates them by id, as `update` does. This covers top-level and nested `updateMany`. Co-Authored-By: Claude Opus 5.5 --- .../orm/src/client/crud/operations/base.ts | 112 ++++++++++++++---- tests/e2e/orm/client-api/delegate.test.ts | 71 +++++++++++ tests/e2e/orm/client-api/update-many.test.ts | 72 +++++++++++ 3 files changed, 232 insertions(+), 23 deletions(-) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a9b9e572e..029cbe578 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -1182,28 +1182,11 @@ export abstract class BaseOperationHandler { const origWhere = combinedWhere; const modelDef = this.requireModel(model); - let finalData = data; // fill in automatically updated fields - const autoUpdatedFields: string[] = []; - for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { - if (fieldDef.updatedAt && finalData[fieldName] === undefined) { - const ignoredFields = new Set(typeof fieldDef.updatedAt === 'boolean' ? [] : fieldDef.updatedAt.ignore); - const hasNonIgnoredFields = Object.keys(data).some( - (field) => - (isScalarField(this.schema, modelDef.name, field) || - isForeignKeyField(this.schema, modelDef.name, field)) && - !ignoredFields.has(field), - ); - if (hasNonIgnoredFields) { - if (finalData === data) { - finalData = clone(data); - } - finalData[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef); - autoUpdatedFields.push(fieldName); - } - } - } + const filled = this.fillUpdatedAtFields(modelDef, data); + let finalData = filled.data; + const autoUpdatedFields = filled.autoUpdatedFields; // lazily load the entity to be updated let thisEntity: any; @@ -1602,14 +1585,24 @@ export abstract class BaseOperationHandler { combinedWhere = Object.keys(combinedWhere).length > 0 ? { AND: [parentWhere, combinedWhere] } : parentWhere; } + // fill in automatically updated fields + const { data: finalData } = this.fillUpdatedAtFields(modelDef, data); + + if (modelDef.baseModel && filterModel === undefined) { + // the base model's table is written before this model's table, so a filter that + // reads a field changed by that write (an `@updatedAt` field, for example) would + // no longer match; resolve the matching rows first and update them by id + return this.updateManyByIds(kysely, model, combinedWhere, finalData, returnData, fieldsToReturn); + } + filterModel ??= model; let updateFields: any = {}; - for (const field in data) { + for (const field in finalData) { if (isRelationField(this.schema, model, field)) { continue; } - updateFields[field] = this.processScalarFieldUpdateData(model, field, data); + updateFields[field] = this.processScalarFieldUpdateData(model, field, finalData); } let resultFromBaseModel: any = undefined; @@ -1751,6 +1744,79 @@ export abstract class BaseOperationHandler { } } + private async updateManyByIds( + kysely: AnyKysely, + model: string, + where: any, + data: any, + returnData: ReturnData, + fieldsToReturn?: readonly string[], + ): Promise { + const idFields = requireIdFields(this.schema, model); + const rows = await this.read(kysely, model, { + where, + select: Object.fromEntries(idFields.map((f) => [f, true])), + } as any); + + // keep each statement within the database limits on parameters + const batchSize = idFields.length === 1 ? 1000 : 100; + let count = 0; + const returned: unknown[] = []; + for (let i = 0; i < rows.length; i += batchSize) { + const batch = rows.slice(i, i + batchSize); + const idWhere = + idFields.length === 1 + ? { [idFields[0]!]: { in: batch.map((row) => row[idFields[0]!]) } } + : { OR: batch.map((row) => Object.fromEntries(idFields.map((f) => [f, row[f]]))) }; + const result: any = await this.updateMany( + kysely, + model, + idWhere, + data, + undefined, + returnData, + model, + undefined, + fieldsToReturn, + ); + if (returnData) { + returned.push(...result); + } else { + count += result.count; + } + } + return (returnData ? returned : { count }) as Result; + } + + /** + * Sets `@updatedAt` fields to the current time when `data` changes a scalar or foreign key + * field that is not in the field's `ignore` list. A value given explicitly in `data` is kept. + * Returns the original `data` object when nothing is filled in, otherwise a clone. + */ + private fillUpdatedAtFields(modelDef: ModelDef, data: any) { + let result = data; + const autoUpdatedFields: string[] = []; + for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { + if (fieldDef.updatedAt && result[fieldName] === undefined) { + const ignoredFields = new Set(typeof fieldDef.updatedAt === 'boolean' ? [] : fieldDef.updatedAt.ignore); + const hasNonIgnoredFields = Object.keys(data).some( + (field) => + (isScalarField(this.schema, modelDef.name, field) || + isForeignKeyField(this.schema, modelDef.name, field)) && + !ignoredFields.has(field), + ); + if (hasNonIgnoredFields) { + if (result === data) { + result = clone(data); + } + result[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef); + autoUpdatedFields.push(fieldName); + } + } + } + return { data: result, autoUpdatedFields }; + } + private async processBaseModelUpdateMany( kysely: AnyKysely, model: string, @@ -1905,7 +1971,7 @@ export abstract class BaseOperationHandler { item.data, item.limit, false, - fieldModel, + undefined, fromRelationContext, ); } diff --git a/tests/e2e/orm/client-api/delegate.test.ts b/tests/e2e/orm/client-api/delegate.test.ts index af205c40e..5133ddb3e 100644 --- a/tests/e2e/orm/client-api/delegate.test.ts +++ b/tests/e2e/orm/client-api/delegate.test.ts @@ -813,6 +813,77 @@ describe('Delegate model tests ', () => { ).rejects.toThrow('Updating with a limit is not supported for polymorphic models'); }); + it('sets base model updatedAt with updateMany', async () => { + const OLD = new Date('2020-01-01T00:00:00.000Z'); + await client.asset.update({ where: { id: 1 }, data: { updatedAt: OLD } }); + + // only sub model fields change + await expect(client.ratedVideo.updateMany({ where: { id: 1 }, data: { rating: 3 } })).resolves.toEqual({ + count: 1, + }); + + const video = await client.ratedVideo.findUniqueOrThrow({ where: { id: 1 } }); + expect(video.rating).toBe(3); + expect(video.updatedAt.getTime()).toBeGreaterThan(OLD.getTime()); + }); + + it('updateMany on a sub model matches rows before the base model is written', async () => { + const OLD = new Date('2020-01-01T00:00:00.000Z'); + await client.asset.update({ where: { id: 1 }, data: { updatedAt: OLD } }); + + // the filter reads the base model's updatedAt, which the base write changes + await expect( + client.ratedVideo.updateMany({ + where: { updatedAt: { lt: new Date('2021-01-01T00:00:00.000Z') } }, + data: { rating: 3 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect(client.ratedVideo.findUniqueOrThrow({ where: { id: 1 } })).resolves.toMatchObject({ + rating: 3, + }); + + // the filter reads a base model field that the update changes + await expect( + client.ratedVideo.updateMany({ + where: { duration: 100 }, + data: { duration: 200, rating: 4 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect(client.ratedVideo.findUniqueOrThrow({ where: { id: 1 } })).resolves.toMatchObject({ + duration: 200, + rating: 4, + }); + }); + + it('nested updateMany on a sub model matches rows before the base model is written', async () => { + const OLD = new Date('2020-01-01T00:00:00.000Z'); + await client.ratedVideo.create({ + data: { id: 2, duration: 100, url: 'v2', rating: 5 }, + }); + await client.asset.updateMany({ data: { updatedAt: OLD } }); + + await client.user.update({ + where: { id: 1 }, + data: { + ratedVideos: { + updateMany: { + where: { updatedAt: { lt: new Date('2021-01-01T00:00:00.000Z') } }, + data: { rating: 3 }, + }, + }, + }, + }); + + const video1 = await client.ratedVideo.findUniqueOrThrow({ where: { id: 1 } }); + expect(video1.rating).toBe(3); + expect(video1.updatedAt.getTime()).toBeGreaterThan(OLD.getTime()); + + // not related to the user, so not updated + const video2 = await client.ratedVideo.findUniqueOrThrow({ where: { id: 2 } }); + expect(video2.rating).toBe(5); + expect(video2.updatedAt.getTime()).toEqual(OLD.getTime()); + }); + it('works with updateManyAndReturn', async () => { if (client.$schema.provider.type === ('mysql' as any)) { return; diff --git a/tests/e2e/orm/client-api/update-many.test.ts b/tests/e2e/orm/client-api/update-many.test.ts index f47fe1a76..ebb15dbc9 100644 --- a/tests/e2e/orm/client-api/update-many.test.ts +++ b/tests/e2e/orm/client-api/update-many.test.ts @@ -127,4 +127,76 @@ describe('Client updateMany tests', () => { // @ts-expect-error expect(r1[0]!.email).toBeUndefined(); }); + + describe('@updatedAt', () => { + const OLD = new Date('2020-01-01T00:00:00.000Z'); + + async function createUserWithOldUpdatedAt(id: string) { + await client.user.create({ data: { id, email: `${id}@test.com`, name: id } }); + // an explicit value is kept by `update` + await client.user.update({ where: { id }, data: { updatedAt: OLD } }); + } + + async function updatedAtOf(id: string) { + const user = await client.user.findUniqueOrThrow({ where: { id } }); + return user.updatedAt.getTime(); + } + + it('sets updatedAt on the matched rows only', async () => { + await createUserWithOldUpdatedAt('1'); + await createUserWithOldUpdatedAt('2'); + + await expect(client.user.updateMany({ where: { id: '1' }, data: { name: 'Foo' } })).resolves.toMatchObject({ + count: 1, + }); + + expect(await updatedAtOf('1')).toBeGreaterThan(OLD.getTime()); + expect(await updatedAtOf('2')).toEqual(OLD.getTime()); + }); + + it('keeps an explicit updatedAt', async () => { + await createUserWithOldUpdatedAt('1'); + const explicit = new Date('2021-06-01T00:00:00.000Z'); + + await client.user.updateMany({ data: { name: 'Foo', updatedAt: explicit } }); + + expect(await updatedAtOf('1')).toEqual(explicit.getTime()); + }); + + it('does not set updatedAt if only ignored fields are present', async () => { + await createUserWithOldUpdatedAt('1'); + + await client.user.updateMany({ data: { createdAt: new Date() } }); + + expect(await updatedAtOf('1')).toEqual(OLD.getTime()); + }); + + it('sets updatedAt with updateManyAndReturn', async () => { + if (client.$schema.provider.type === ('mysql' as any)) { + // skip for mysql as it does not support returning + return; + } + await createUserWithOldUpdatedAt('1'); + + const [user] = await client.user.updateManyAndReturn({ data: { name: 'Foo' } }); + + expect(user!.updatedAt.getTime()).toBeGreaterThan(OLD.getTime()); + expect(await updatedAtOf('1')).toEqual(user!.updatedAt.getTime()); + }); + + it('sets updatedAt with nested updateMany', async () => { + await createUserWithOldUpdatedAt('1'); + const post = await client.post.create({ data: { title: 'Post1', authorId: '1' } }); + await client.post.update({ where: { id: post.id }, data: { updatedAt: OLD } }); + + await client.user.update({ + where: { id: '1' }, + data: { posts: { updateMany: { where: {}, data: { title: 'Post2' } } } }, + }); + + const updatedPost = await client.post.findUniqueOrThrow({ where: { id: post.id } }); + expect(updatedPost.title).toBe('Post2'); + expect(updatedPost.updatedAt.getTime()).toBeGreaterThan(OLD.getTime()); + }); + }); }); From ca9b2e52f7d870a80778b3c04da464a0cb501b68 Mon Sep 17 00:00:00 2001 From: Erik Dakoda Date: Tue, 13 Oct 2026 14:21:28 -0400 Subject: [PATCH 2/2] docs(orm): describe updateManyByIds Co-Authored-By: Claude Opus 5.5 --- packages/orm/src/client/crud/operations/base.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 029cbe578..0361ff1f7 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -1744,6 +1744,11 @@ export abstract class BaseOperationHandler { } } + /** + * Runs `updateMany` on a delegate sub model by id. Reads the ids of the rows that match + * `where`, then updates them in batches with an id filter. Callers run this inside a + * transaction (`needsNestedUpdate` is true for every model with a base model). + */ private async updateManyByIds( kysely: AnyKysely, model: string,