Skip to content

Commit 4900d08

Browse files
authored
fix(delegate): simulate cascade delete (#2120)
1 parent 03d6f7f commit 4900d08

File tree

4 files changed

+174
-5
lines changed

4 files changed

+174
-5
lines changed

packages/runtime/src/cross/model-meta.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export type RuntimeAttribute = {
2020
*/
2121
export type FieldDefaultValueProvider = (userContext: unknown) => unknown;
2222

23+
/**
24+
* Action to take when the related model is deleted or updated
25+
*/
26+
export type RelationAction = 'Cascade' | 'Restrict' | 'NoAction' | 'SetNull' | 'SetDefault';
27+
2328
/**
2429
* Runtime information of a data model field
2530
*/
@@ -74,6 +79,16 @@ export type FieldInfo = {
7479
*/
7580
isRelationOwner?: boolean;
7681

82+
/**
83+
* Action to take when the related model is deleted.
84+
*/
85+
onDeleteAction?: RelationAction;
86+
87+
/**
88+
* Action to take when the related model is updated.
89+
*/
90+
onUpdateAction?: RelationAction;
91+
7792
/**
7893
* If the field is a foreign key field
7994
*/

packages/runtime/src/enhancements/node/delegate.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,19 +1160,90 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
11601160
}
11611161
}
11621162

1163-
private async doDelete(db: CrudContract, model: string, args: any): Promise<unknown> {
1163+
private async doDelete(db: CrudContract, model: string, args: any, readBack = true): Promise<unknown> {
11641164
this.injectWhereHierarchy(model, args.where);
11651165
await this.injectSelectIncludeHierarchy(model, args);
11661166

1167+
// read relation entities that need to be cascade deleted before deleting the main entity
1168+
const cascadeDeletes = await this.getRelationDelegateEntitiesForCascadeDelete(db, model, args.where);
1169+
1170+
let result: unknown = undefined;
1171+
if (cascadeDeletes.length > 0) {
1172+
// we'll need to do cascade deletes of relations, so first
1173+
// read the current entity before anything changes
1174+
if (readBack) {
1175+
result = await this.doFind(db, model, 'findUnique', args);
1176+
}
1177+
1178+
// process cascade deletes of relations, this ensure their delegate base
1179+
// entities are deleted as well
1180+
await Promise.all(
1181+
cascadeDeletes.map(({ model, entity }) => this.doDelete(db, model, { where: entity }, false))
1182+
);
1183+
}
1184+
11671185
if (this.options.logPrismaQuery) {
11681186
this.logger.info(`[delegate] \`delete\` ${this.getModelName(model)}: ${formatObject(args)}`);
11691187
}
1170-
const result = await db[model].delete(args);
1171-
const idValues = this.queryUtils.getEntityIds(model, result);
1188+
1189+
const deleteResult = await db[model].delete(args);
1190+
if (!result) {
1191+
result = this.assembleHierarchy(model, deleteResult);
1192+
}
11721193

11731194
// recursively delete base entities (they all have the same id values)
1195+
const idValues = this.queryUtils.getEntityIds(model, deleteResult);
11741196
await this.deleteBaseRecursively(db, model, idValues);
1175-
return this.assembleHierarchy(model, result);
1197+
1198+
return result;
1199+
}
1200+
1201+
private async getRelationDelegateEntitiesForCascadeDelete(db: CrudContract, model: string, where: any) {
1202+
if (!where || Object.keys(where).length === 0) {
1203+
throw new Error('where clause is required for cascade delete');
1204+
}
1205+
1206+
const cascadeDeletes: Array<{ model: string; entity: any }> = [];
1207+
const fields = getFields(this.options.modelMeta, model);
1208+
if (fields) {
1209+
for (const fieldInfo of Object.values(fields)) {
1210+
if (!fieldInfo.isDataModel) {
1211+
continue;
1212+
}
1213+
1214+
if (fieldInfo.isRelationOwner) {
1215+
// this side of the relation owns the foreign key,
1216+
// so it won't cause cascade delete to the other side
1217+
continue;
1218+
}
1219+
1220+
if (fieldInfo.backLink) {
1221+
// get the opposite side of the relation
1222+
const backLinkField = this.queryUtils.getModelField(fieldInfo.type, fieldInfo.backLink);
1223+
1224+
if (backLinkField?.isRelationOwner && this.isFieldCascadeDelete(backLinkField)) {
1225+
// if the opposite side of the relation is to be cascade deleted,
1226+
// recursively delete the delegate base entities
1227+
const relationModel = getModelInfo(this.options.modelMeta, fieldInfo.type);
1228+
if (relationModel?.baseTypes && relationModel.baseTypes.length > 0) {
1229+
// the relation model has delegate base, cascade the delete to the base
1230+
const relationEntities = await db[relationModel.name].findMany({
1231+
where: { [backLinkField.name]: where },
1232+
select: this.queryUtils.makeIdSelection(relationModel.name),
1233+
});
1234+
relationEntities.forEach((entity) => {
1235+
cascadeDeletes.push({ model: fieldInfo.type, entity });
1236+
});
1237+
}
1238+
}
1239+
}
1240+
}
1241+
}
1242+
return cascadeDeletes;
1243+
}
1244+
1245+
private isFieldCascadeDelete(fieldInfo: FieldInfo) {
1246+
return fieldInfo.onDeleteAction === 'Cascade';
11761247
}
11771248

11781249
// #endregion

packages/sdk/src/model-meta-generator.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,18 @@ function writeFields(
311311
isRelationOwner: true,`);
312312
}
313313

314+
const onDeleteAction = getOnDeleteAction(dmField);
315+
if (onDeleteAction) {
316+
writer.write(`
317+
onDeleteAction: '${onDeleteAction}',`);
318+
}
319+
320+
const onUpdateAction = getOnUpdateAction(dmField);
321+
if (onUpdateAction) {
322+
writer.write(`
323+
onUpdateAction: '${onUpdateAction}',`);
324+
}
325+
314326
if (isForeignKeyField(dmField)) {
315327
writer.write(`
316328
isForeignKey: true,`);
@@ -568,3 +580,25 @@ function writeShortNameMap(options: ModelMetaGeneratorOptions, writer: CodeWrite
568580
writer.write(',');
569581
}
570582
}
583+
584+
function getOnDeleteAction(fieldInfo: DataModelField) {
585+
const relationAttr = getAttribute(fieldInfo, '@relation');
586+
if (relationAttr) {
587+
const onDelete = getAttributeArg(relationAttr, 'onDelete');
588+
if (onDelete && isEnumFieldReference(onDelete)) {
589+
return onDelete.target.ref?.name;
590+
}
591+
}
592+
return undefined;
593+
}
594+
595+
function getOnUpdateAction(fieldInfo: DataModelField) {
596+
const relationAttr = getAttribute(fieldInfo, '@relation');
597+
if (relationAttr) {
598+
const onUpdate = getAttributeArg(relationAttr, 'onUpdate');
599+
if (onUpdate && isEnumFieldReference(onUpdate)) {
600+
return onUpdate.target.ref?.name;
601+
}
602+
}
603+
return undefined;
604+
}

tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1057,7 +1057,7 @@ describe('Polymorphism Test', () => {
10571057
expect(created.duration).toBe(300);
10581058
});
10591059

1060-
it('delete', async () => {
1060+
it('delete simple', async () => {
10611061
let { db, user, video: ratedVideo } = await setup();
10621062

10631063
let deleted = await db.ratedVideo.delete({
@@ -1106,6 +1106,55 @@ describe('Polymorphism Test', () => {
11061106
await expect(db.asset.findUnique({ where: { id: ratedVideo.id } })).resolves.toBeNull();
11071107
});
11081108

1109+
it('delete cascade', async () => {
1110+
const { prisma, enhance } = await loadSchema(
1111+
`
1112+
model Base {
1113+
id Int @id @default(autoincrement())
1114+
type String
1115+
@@delegate(type)
1116+
}
1117+
1118+
model List extends Base {
1119+
name String
1120+
items Item[]
1121+
}
1122+
1123+
model Item extends Base {
1124+
name String
1125+
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
1126+
listId Int
1127+
content ItemContent?
1128+
}
1129+
1130+
model ItemContent extends Base {
1131+
name String
1132+
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
1133+
itemId Int @unique
1134+
}
1135+
`,
1136+
{ enhancements: ['delegate'], logPrismaQuery: true }
1137+
);
1138+
1139+
const db = enhance();
1140+
await db.list.create({
1141+
data: {
1142+
id: 1,
1143+
name: 'list',
1144+
items: {
1145+
create: [{ id: 2, name: 'item1', content: { create: { id: 3, name: 'content1' } } }],
1146+
},
1147+
},
1148+
});
1149+
1150+
const r = await db.list.delete({ where: { id: 1 }, include: { items: { include: { content: true } } } });
1151+
expect(r).toMatchObject({ items: [{ id: 2 }] });
1152+
await expect(db.item.findUnique({ where: { id: 2 } })).toResolveNull();
1153+
await expect(prisma.base.findUnique({ where: { id: 2 } })).toResolveNull();
1154+
await expect(db.itemContent.findUnique({ where: { id: 3 } })).toResolveNull();
1155+
await expect(prisma.base.findUnique({ where: { id: 3 } })).toResolveNull();
1156+
});
1157+
11091158
it('deleteMany', async () => {
11101159
const { enhance } = await loadSchema(schema, { enhancements: ['delegate'] });
11111160
const db = enhance();

0 commit comments

Comments
 (0)