From 426f5aafe8b33cfad6cfcb2f9bc3850a944abceb Mon Sep 17 00:00:00 2001 From: Erik Dakoda Date: Mon, 5 Oct 2026 17:50:33 -0400 Subject: [PATCH 1/2] fix(orm): stop delegate cascade delete recursion and simulate sub model cascades Deleting from a delegate hierarchy whose base model has a cascade relation back into the hierarchy overflowed the stack, because the walk recursed with an ever deeper `where` without checking for matching rows. Cascade relations declared on a sub model were never walked, which left the children's base rows behind (the v3 form of #2102). - walk cascade relations once, at the base of the hierarchy, including relations declared on sub models, filtered by discriminator - for a delegate model, resolve the rows first (honouring `limit`), delete children by id, then delete the rows by id - look up children by foreign key when it references the parent id - track rows already being deleted, keyed by the hierarchy's base model, so cyclic data terminates - send id lists in batches to stay within parameter and depth limits - keep regular models on the relation-filter path, as before Co-Authored-By: Claude Opus 5.5 --- .../orm/src/client/crud/operations/base.ts | 234 +++++++++++++--- .../orm/src/client/crud/operations/delete.ts | 16 +- .../delegate-cascade-delete.test.ts | 259 ++++++++++++++++++ 3 files changed, 465 insertions(+), 44 deletions(-) create mode 100644 tests/e2e/orm/client-api/delegate-cascade-delete.test.ts diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a9b9e572e..61ed1f511 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -36,6 +36,7 @@ import { ensureArray, extractIdFields, flattenCompoundUniqueFilters, + getDelegateDescendantModels, getDelegateDiscriminatorValue, getDiscriminatorField, getField, @@ -179,6 +180,16 @@ export const AllWriteOperations = CoreWriteOperations; */ export type AllWriteOperations = CoreWriteOperations; +/** + * Maximum number of rows identified by id in a single statement during cascade delete simulation. + */ +const DELETE_ID_BATCH_SIZE = 1000; + +/** + * Same as `DELETE_ID_BATCH_SIZE`, for compound ids, which are matched with one `OR` branch per row. + */ +const DELETE_COMPOUND_ID_BATCH_SIZE = 100; + // context for nested relation operations export type FromRelationContext = { // the model where the relation field is defined @@ -2376,8 +2387,10 @@ export abstract class BaseOperationHandler { limit?: number, filterModel?: string, fieldsToReturn?: readonly string[], + visited?: Set, ): Promise> { filterModel ??= model; + visited ??= new Set(); const modelDef = this.requireModel(model); @@ -2386,9 +2399,109 @@ export abstract class BaseOperationHandler { throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); } // just delete base and it'll cascade back to this model - return this.processBaseModelDelete(kysely, modelDef.baseModel, where, limit, filterModel); + return this.processBaseModelDelete(kysely, modelDef.baseModel, where, limit, filterModel, visited); + } + + // if the model being deleted has a relation to a model that extends a delegate model, and if that + // relation is set to trigger a cascade delete from this model, the deletion will not automatically + // clean up the base hierarchy of the relation side (because polymorphic model's cascade deletion + // works downward not upward). We need to take care of the base deletions manually here. + const cascadeRelations = this.getDelegateCascadeRelations(model, filterModel); + if (cascadeRelations.length === 0) { + return this.executeDelete(kysely, model, where, limit, filterModel, fieldsToReturn); + } + + if (!modelDef.isDelegate) { + if (limit !== undefined) { + throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); + } + for (const { fieldDef } of cascadeRelations) { + await this.delete( + kysely, + fieldDef.type, + { [fieldDef.relation!.opposite!]: where }, + undefined, + undefined, + undefined, + visited, + ); + } + return this.executeDelete(kysely, model, where, limit, filterModel, fieldsToReturn); + } + + // for a delegate model, resolve the rows being deleted first: `where` can filter on a sub model or on + // related rows, which the cascades below may delete + const discriminator = getDiscriminatorField(this.schema, model)!; + const deletedRows = await this.read(kysely, filterModel, { + where, + select: { ...this.makeIdSelect(filterModel), [discriminator]: true }, + ...(limit !== undefined ? { take: limit } : {}), + }); + + // rows already being deleted in this chain are skipped, otherwise cyclic data recurses forever + deletedRows.forEach((row) => visited.add(this.makeVisitedKey(model, row))); + + for (const { fieldDef, discriminatorValue } of cascadeRelations) { + const parentRows = + discriminatorValue === undefined + ? deletedRows + : deletedRows.filter((row) => row[discriminator] === discriminatorValue); + for (const batch of this.batchRows(model, parentRows)) { + const childModel = fieldDef.type; + const childRows = await this.read(kysely, childModel, { + where: this.makeChildFilter(model, fieldDef, batch) as any, + select: this.makeIdSelect(childModel), + }); + const pendingRows = childRows.filter((row) => { + const key = this.makeVisitedKey(childModel, row); + if (visited.has(key)) { + return false; + } + visited.add(key); + return true; + }); + + // the deletion will propagate upward to the base model chain + for (const childBatch of this.batchRows(childModel, pendingRows)) { + await this.delete( + kysely, + childModel, + this.makeIdsFilter(childModel, childBatch), + undefined, + undefined, + undefined, + visited, + ); + } + } } + let numAffectedRows = 0n; + const rows: unknown[] = []; + for (const batch of this.batchRows(model, deletedRows)) { + const result = await this.executeDelete( + kysely, + model, + this.makeIdsFilter(model, batch), + undefined, + model, + fieldsToReturn, + ); + numAffectedRows += result.numAffectedRows ?? 0n; + rows.push(...result.rows); + } + return { numAffectedRows, rows }; + } + + private async executeDelete( + kysely: AnyKysely, + model: string, + where: any, + limit: number | undefined, + filterModel: string, + fieldsToReturn: readonly string[] | undefined, + ): Promise> { + const modelDef = this.requireModel(model); fieldsToReturn = fieldsToReturn ?? requireIdFields(this.schema, model); let needIdFilter = false; @@ -2428,13 +2541,6 @@ export abstract class BaseOperationHandler { ) : () => this.dialect.buildFilter(model, model, where); - // if the model being deleted has a relation to a model that extends a delegate model, and if that - // relation is set to trigger a cascade delete from this model, the deletion will not automatically - // clean up the base hierarchy of the relation side (because polymorphic model's cascade deletion - // works downward not upward). We need to take care of the base deletions manually here. - - await this.processDelegateRelationDelete(kysely, modelDef, where, limit); - const query = kysely .deleteFrom(model) .where(deleteFilter) @@ -2445,32 +2551,97 @@ export abstract class BaseOperationHandler { return this.executeQuery(kysely, query, 'delete'); } - private async processDelegateRelationDelete( - kysely: ToKysely, - modelDef: ModelDef, - where: any, - limit: number | undefined, - ) { - for (const fieldDef of Object.values(modelDef.fields)) { - if (fieldDef.relation && fieldDef.relation.opposite) { - const oppositeModelDef = this.requireModel(fieldDef.type); - const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); - if (oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade') { - if (limit !== undefined) { - throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); + /** + * Gets the relations whose deletion must be simulated when deleting from the given model: relations + * pointing to a model that extends a delegate model, with cascade delete on the opposite side. For a + * delegate model, this includes relations declared on its sub models, because deleting a base row + * cascades to the sub model rows in the database. `discriminatorValue` is set for a sub model relation: + * it is the base model's discriminator value of the rows the relation can belong to. + */ + protected getDelegateCascadeRelations(model: string, filterModel = model) { + const modelDef = this.requireModel(model); + // when deleting through a sub model, only relations its rows can have apply + const filterDiscriminatorValue = + filterModel === model ? undefined : this.getDiscriminatorValueBelow(model, filterModel); + const candidates = Object.values(modelDef.fields).map((fieldDef) => ({ + fieldDef, + discriminatorValue: undefined as unknown, + })); + if (modelDef.isDelegate) { + for (const subModel of getDelegateDescendantModels(this.schema, model)) { + // the sub model's ancestor directly below `model` gives the discriminator value its rows carry + const discriminatorValue = this.getDiscriminatorValueBelow(model, subModel.name); + if (filterDiscriminatorValue !== undefined && discriminatorValue !== filterDiscriminatorValue) { + continue; + } + for (const fieldDef of Object.values(subModel.fields)) { + if (!fieldDef.originModel) { + candidates.push({ fieldDef, discriminatorValue }); } - // the deletion will propagate upward to the base model chain - await this.delete( - kysely, - fieldDef.type as GetModels, - { - [fieldDef.relation.opposite]: where, - }, - undefined, - ); } } } + + return candidates.filter(({ fieldDef }) => { + if (!fieldDef.relation?.opposite) { + return false; + } + const oppositeModelDef = this.requireModel(fieldDef.type); + const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); + return !!oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade'; + }); + } + + // gets the discriminator value that `model` stores for rows of `subModel`, which is the value of + // `subModel`'s ancestor directly below `model` + private getDiscriminatorValueBelow(model: string, subModel: string) { + let ancestor = this.requireModel(subModel); + while (ancestor.baseModel !== model) { + ancestor = this.requireModel(ancestor.baseModel!); + } + return getDelegateDiscriminatorValue(this.schema, ancestor.name); + } + + private makeVisitedKey(model: string, row: any) { + // key by the root of the delegate hierarchy, since a row can be reached through any model in it + let root = model; + for (let base = this.requireModel(root).baseModel; base; base = this.requireModel(root).baseModel) { + root = base; + } + return JSON.stringify([root, ...this.requireModel(model).idFields.map((f) => row[f])]); + } + + // filters the children of the given parent rows, by foreign key when it references the parent's id + private makeChildFilter(model: string, fieldDef: FieldDef, parentRows: any[]) { + const opposite = fieldDef.relation!.opposite!; + const { fields, references } = this.requireField(fieldDef.type, opposite).relation ?? {}; + const idFields = this.requireModel(model).idFields; + if (!fields || !references || !references.every((f) => idFields.includes(f))) { + return { [opposite]: this.makeIdsFilter(model, parentRows) }; + } + if (fields.length === 1) { + return { [fields[0]!]: { in: parentRows.map((row) => row[references[0]!]) } }; + } + return { + OR: parentRows.map((row) => Object.fromEntries(fields.map((f, i) => [f, row[references[i]!]]))), + }; + } + + private makeIdsFilter(model: string, rows: any[]) { + const idFields = this.requireModel(model).idFields; + if (idFields.length === 1) { + return { [idFields[0]!]: { in: rows.map((row) => row[idFields[0]!]) } }; + } + return { OR: rows.map((row) => Object.fromEntries(idFields.map((f) => [f, row[f]]))) }; + } + + private *batchRows(model: string, rows: T[]) { + // keeps id lists within the databases' limits on query parameters and expression depth + const size = + this.requireModel(model).idFields.length === 1 ? DELETE_ID_BATCH_SIZE : DELETE_COMPOUND_ID_BATCH_SIZE; + for (let i = 0; i < rows.length; i += size) { + yield rows.slice(i, i + size); + } } private async processBaseModelDelete( @@ -2479,8 +2650,9 @@ export abstract class BaseOperationHandler { where: any, limit: number | undefined, filterModel: string, + visited: Set, ) { - return this.delete(kysely, model, where, limit, filterModel); + return this.delete(kysely, model, where, limit, filterModel, undefined, visited); } protected makeIdSelect(model: string) { diff --git a/packages/orm/src/client/crud/operations/delete.ts b/packages/orm/src/client/crud/operations/delete.ts index 03addadc3..2a1f33bde 100644 --- a/packages/orm/src/client/crud/operations/delete.ts +++ b/packages/orm/src/client/crud/operations/delete.ts @@ -68,18 +68,8 @@ export class DeleteOperationHandler extends BaseOperat return true; } - // Check if any relation points to a delegate sub-model with cascade delete, - // which would trigger processDelegateRelationDelete in BaseOperationHandler.delete() - for (const fieldDef of Object.values(modelDef.fields)) { - if (fieldDef.relation?.opposite) { - const oppositeModelDef = this.requireModel(fieldDef.type); - const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); - if (oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade') { - return true; - } - } - } - - return false; + // Check if any relation points to a delegate sub-model with cascade delete, which + // BaseOperationHandler.delete() simulates with nested deletes + return this.getDelegateCascadeRelations(this.model).length > 0; } } diff --git a/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts b/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts new file mode 100644 index 000000000..0ab017f1d --- /dev/null +++ b/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts @@ -0,0 +1,259 @@ +import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe('Delegate cascade delete', () => { + let db: any; + + afterEach(async () => { + await db?.$disconnect(); + }); + + async function itemIds() { + const rows = await db.$qb.selectFrom('Item').select('id').execute(); + return rows.map((r: { id: string }) => r.id).sort(); + } + + describe('with a base model relation that cascades back into the hierarchy', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + notesAsSource Note[] @relation("NoteSource") + + @@delegate(itemKind) +} + +model Task extends Item {} + +model Note extends Item { + sourceId String? + source Item? @relation("NoteSource", fields: [sourceId], references: [id], onDelete: Cascade) + threadId String? + thread Thread? @relation(fields: [threadId], references: [id], onDelete: Cascade) +} + +model Thread { + id String @id @default(cuid()) + notes Note[] +} +`; + + it('deletes a row with no related rows', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades through the base model relation', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.note.create({ data: { sourceId: task.id } }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades through a chain of rows', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + const parent = await db.note.create({ data: { sourceId: task.id } }); + await db.note.create({ data: { sourceId: parent.id } }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades from a non-delegate model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const thread = await db.thread.create({ data: {} }); + const note = await db.note.create({ data: { threadId: thread.id } }); + await db.note.create({ data: { sourceId: note.id } }); + await db.thread.delete({ where: { id: thread.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('deletes cyclic data through the sub model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const first = await db.note.create({ data: {} }); + const second = await db.note.create({ data: { sourceId: first.id } }); + await db.note.update({ where: { id: first.id }, data: { sourceId: second.id } }); + await db.note.delete({ where: { id: first.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('deletes cyclic data through the base model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const first = await db.note.create({ data: {} }); + const second = await db.note.create({ data: { sourceId: first.id } }); + await db.note.update({ where: { id: first.id }, data: { sourceId: second.id } }); + await expect(db.item.deleteMany({ where: { id: first.id } })).resolves.toEqual({ count: 1 }); + expect(await itemIds()).toEqual([]); + + const third = await db.note.create({ data: {} }); + const fourth = await db.note.create({ data: { sourceId: third.id } }); + await db.note.update({ where: { id: third.id }, data: { sourceId: fourth.id } }); + await db.item.delete({ where: { id: third.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('deletes many rows filtered by a sub model field', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const thread = await db.thread.create({ data: {} }); + const task = await db.task.create({ data: {} }); + const kept = await db.note.create({ data: { sourceId: task.id } }); + const deleted = await db.note.create({ data: { threadId: thread.id } }); + await db.note.create({ data: { sourceId: deleted.id } }); + await expect(db.note.deleteMany({ where: { threadId: thread.id } })).resolves.toEqual({ count: 1 }); + expect(await itemIds()).toEqual([kept.id, task.id].sort()); + }); + + it('deletes rows filtered by the related rows it cascades to', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.note.create({ data: { sourceId: task.id } }); + await db.task.create({ data: {} }); + await expect(db.task.deleteMany({ where: { notesAsSource: { some: {} } } })).resolves.toEqual({ + count: 1, + }); + expect(await itemIds()).toHaveLength(1); + }); + }); + + describe('with a sub model relation', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + + @@delegate(itemKind) +} + +model Task extends Item { + comments Comment[] +} + +model Comment extends Item { + taskId String? + task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade) +} +`; + + it('cascades when deleting through the sub model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.comment.create({ data: { taskId: task.id } }); + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('cascades when deleting through the base model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + await db.comment.create({ data: { taskId: task.id } }); + await db.item.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }); + + it('applies a limit when deleting through the base model', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + for (let i = 0; i < 2; i++) { + const task = await db.task.create({ data: {} }); + await db.comment.create({ data: { taskId: task.id } }); + } + await expect(db.item.deleteMany({ where: { itemKind: 'Task' }, limit: 1 })).resolves.toEqual({ + count: 1, + }); + expect(await db.task.count()).toBe(1); + expect(await db.comment.count()).toBe(1); + expect(await itemIds()).toHaveLength(2); + }); + + it('deletes more related rows than fit in one statement', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + const task = await db.task.create({ data: {} }); + const ids = Array.from({ length: 33000 }, (_, i) => `c${String(i).padStart(5, '0')}`); + for (let i = 0; i < ids.length; i += 1000) { + const batch = ids.slice(i, i + 1000); + await db.$qb + .insertInto('Item') + .values(batch.map((id) => ({ id, itemKind: 'Comment' }))) + .execute(); + await db.$qb + .insertInto('Comment') + .values(batch.map((id) => ({ id, taskId: task.id }))) + .execute(); + } + await db.task.delete({ where: { id: task.id } }); + expect(await itemIds()).toEqual([]); + }, 120_000); + }); + + describe('with a compound id', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + + @@delegate(itemKind) +} + +model Note extends Item { + groupA Int + groupB Int + group Group @relation(fields: [groupA, groupB], references: [a, b], onDelete: Cascade) +} + +model Group { + a Int + b Int + notes Note[] + + @@id([a, b]) +} +`; + + it('deletes many rows with compound ids', async () => { + db = await createTestClient(schema, { usePrismaPush: true }); + for (let b = 0; b < 600; b++) { + await db.group.create({ data: { a: 1, b, notes: { create: {} } } }); + } + await expect(db.group.deleteMany({ where: { a: 1 } })).resolves.toEqual({ count: 600 }); + expect(await itemIds()).toEqual([]); + }, 120_000); + }); + + describe('with access policies', () => { + const schema = ` +model Item { + id String @id @default(cuid()) + itemKind String + + @@delegate(itemKind) + @@allow('all', true) +} + +model Note extends Item { + threadId String? + thread Thread? @relation(fields: [threadId], references: [id], onDelete: Cascade) +} + +model Thread { + id String @id @default(cuid()) + hidden Boolean @default(false) + notes Note[] + + @@allow('create,delete', true) + @@allow('read', !hidden) +} +`; + + it('deletes rows that can be deleted but not read', async () => { + db = await createPolicyTestClient(schema, { usePrismaPush: true }); + const raw = db.$unuseAll(); + await raw.thread.create({ data: { id: 'visible', notes: { create: {} } } }); + await raw.thread.create({ data: { id: 'hidden', hidden: true, notes: { create: {} } } }); + await expect(db.thread.deleteMany({})).resolves.toEqual({ count: 2 }); + expect(await raw.thread.count()).toBe(0); + expect(await itemIds()).toEqual([]); + }); + }); +}); From d861d244272b6b4451aaed1036beaad32546f738 Mon Sep 17 00:00:00 2001 From: Erik Dakoda Date: Mon, 5 Oct 2026 18:28:16 -0400 Subject: [PATCH 2/2] test(orm): assert which rows survive in filtered and limited cascade deletes Co-Authored-By: Claude Opus 5.5 --- tests/e2e/orm/client-api/delegate-cascade-delete.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts b/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts index 0ab017f1d..2c50f3a41 100644 --- a/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts +++ b/tests/e2e/orm/client-api/delegate-cascade-delete.test.ts @@ -110,11 +110,11 @@ model Thread { db = await createTestClient(schema, { usePrismaPush: true }); const task = await db.task.create({ data: {} }); await db.note.create({ data: { sourceId: task.id } }); - await db.task.create({ data: {} }); + const kept = await db.task.create({ data: {} }); await expect(db.task.deleteMany({ where: { notesAsSource: { some: {} } } })).resolves.toEqual({ count: 1, }); - expect(await itemIds()).toHaveLength(1); + expect(await itemIds()).toEqual([kept.id]); }); }); @@ -162,6 +162,9 @@ model Comment extends Item { await expect(db.item.deleteMany({ where: { itemKind: 'Task' }, limit: 1 })).resolves.toEqual({ count: 1, }); + const [remainingTask] = await db.task.findMany(); + const [remainingComment] = await db.comment.findMany(); + expect(remainingComment.taskId).toBe(remainingTask.id); expect(await db.task.count()).toBe(1); expect(await db.comment.count()).toBe(1); expect(await itemIds()).toHaveLength(2);