Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 94 additions & 23 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1182,28 +1182,11 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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;
Expand Down Expand Up @@ -1602,14 +1585,24 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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;
Expand Down Expand Up @@ -1751,6 +1744,84 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
}
}

/**
* 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<ReturnData extends boolean, Result>(
kysely: AnyKysely,
model: string,
where: any,
data: any,
returnData: ReturnData,
fieldsToReturn?: readonly string[],
): Promise<Result> {
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 };
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
private async processBaseModelUpdateMany(
kysely: AnyKysely,
model: string,
Expand Down Expand Up @@ -1905,7 +1976,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
item.data,
item.limit,
false,
fieldModel,
undefined,
fromRelationContext,
);
}
Expand Down
71 changes: 71 additions & 0 deletions tests/e2e/orm/client-api/delegate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions tests/e2e/orm/client-api/update-many.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
});
});