diff --git a/packages/plugins/soft-delete/src/plugin.ts b/packages/plugins/soft-delete/src/plugin.ts index 221596a3a..a20cac974 100644 --- a/packages/plugins/soft-delete/src/plugin.ts +++ b/packages/plugins/soft-delete/src/plugin.ts @@ -101,6 +101,16 @@ class SoftDeleteHandler extends OperationNodeTransform if (!info) { return result; } + // Any model that participates in a delegate (polymorphic) hierarchy has its `@deletedAt` + // handled by `transformSelectQuery`, never here: reconstruction joins are filtered through + // the outer WHERE (walking the FROM entity's base chain), and relation reads become subqueries + // filtered by their own FROM. The marker may also live on a table that isn't the one under + // this join's alias (a base, or a descendant joined for polymorphic packing). So never add an + // ON-clause predicate for a hierarchy member — at best it's redundant, and on a LEFT join it + // would only null the joined columns and leak the row. + if (this.isInDelegateHierarchy(info.model)) { + return result; + } const deletedAt = this.getDeletedAtField(info.model); if (!deletedAt) { return result; @@ -183,11 +193,10 @@ class SoftDeleteHandler extends OperationNodeTransform if (!info) { continue; } - const deletedAt = this.getDeletedAtField(info.model); - if (!deletedAt) { - continue; + const target = this.getSoftDeleteTarget(info); + if (target) { + filters.push(this.buildIsNullPredicate(target.table, target.column)); } - filters.push(this.buildIsNullPredicate(info.alias ?? info.model, deletedAt.name)); } if (filters.length === 0) { return undefined; @@ -195,6 +204,32 @@ class SoftDeleteHandler extends OperationNodeTransform return filters.reduce((acc, f) => this.andNode(acc, f)); } + // Resolve the single `@deletedAt` column to filter when reading a table. `@@@onceInModel` allows + // at most one marker per inheritance hierarchy, so it's either declared on the model itself or + // inherited from exactly one delegate base — where the column physically lives on the base's + // table (LEFT-joined under its own model name, see buildDelegateJoin), so the filter must key off + // that base rather than the queried table. + private getSoftDeleteTarget(info: TableInfo): { table: string; column: string } | undefined { + const own = this.getDeletedAtField(info.model); + if (own) { + return { table: info.alias ?? info.model, column: own.name }; + } + let base = QueryUtils.getModel(this.client.$schema, info.model)?.baseModel; + while (base) { + const marker = this.getDeletedAtField(base); + if (marker) { + return { table: base, column: marker.name }; + } + base = QueryUtils.getModel(this.client.$schema, base)?.baseModel; + } + return undefined; + } + + private isInDelegateHierarchy(model: string): boolean { + const modelDef = QueryUtils.getModel(this.client.$schema, model); + return !!modelDef && (!!modelDef.isDelegate || !!modelDef.baseModel); + } + private buildIsNullPredicate(table: string, column: string): OperationNode { return BinaryOperationNode.create( ReferenceNode.create(ColumnNode.create(column), TableNode.create(table)), @@ -239,6 +274,16 @@ class SoftDeleteHandler extends OperationNodeTransform } for (const fieldDef of Object.values(modelDef.fields)) { if (fieldDef.attributes?.some((a) => a.name === SOFT_DELETE_ATTRIBUTE)) { + if (fieldDef.originModel) { + // In a delegate (polymorphic) hierarchy the marker physically lives on the base + // table, but it's surfaced on every concrete sub-model's field list (tagged with + // `originModel`). Skip it here so we don't reference a non-existent + // `.` column — the base model contributes its own FROM/JOIN + // node that carries the soft-delete filter on the real column. Likewise, a + // concrete delete is rewritten by the ORM into a delete on the base table, so the + // delete-to-update conversion also keys off the base model. + continue; + } if (!fieldDef.optional) { // A non-nullable marker can never be null, so the `IS NULL` read filter would // hide every row. Require the marker to be optional so "not deleted" === null. diff --git a/packages/plugins/soft-delete/test/soft-delete.test.ts b/packages/plugins/soft-delete/test/soft-delete.test.ts index 5d37facf5..d4a7eac28 100644 --- a/packages/plugins/soft-delete/test/soft-delete.test.ts +++ b/packages/plugins/soft-delete/test/soft-delete.test.ts @@ -7,8 +7,8 @@ import { SoftDeletePlugin } from '../src'; // test client explicitly so that `@zenstackhq/testtools` doesn't need to depend on this plugin. const PLUGIN_MODEL_FILE = fileURLToPath(new URL('../plugin.zmodel', import.meta.url)); -function createSoftDeleteTestClient(schema: string) { - return createTestClient(schema, { extraPluginModelFiles: [PLUGIN_MODEL_FILE] }); +function createSoftDeleteTestClient(schema: string, extraOptions: Record = {}) { + return createTestClient(schema, { extraPluginModelFiles: [PLUGIN_MODEL_FILE], ...extraOptions }); } const schema = ` @@ -320,4 +320,115 @@ model Child { await expect(raw.parent.findUnique({ where: { id: parent.id } })).resolves.toBeNull(); await expect(raw.child.findMany({ where: { parentId: parent.id } })).resolves.toHaveLength(0); }); + + it('soft-deletes a model whose @deletedAt is inherited from a mixin', async () => { + // The marker is declared on a `type` mixin and flattened into the model's fields, so the + // plugin should treat the composed model exactly like one that declares @deletedAt directly. + const mixinSchema = ` +type SoftDeletable { + deletedAt DateTime? @deletedAt +} + +model Item with SoftDeletable { + id Int @id @default(autoincrement()) + name String +} +`; + const raw = await createSoftDeleteTestClient(mixinSchema); + const db = raw.$use(new SoftDeletePlugin()); + + const a = await db.item.create({ data: { name: 'a' } }); + const b = await db.item.create({ data: { name: 'b' } }); + + await db.item.delete({ where: { id: a.id } }); + + // hidden from reads through the plugin + await expect(db.item.findMany()).resolves.toEqual([ + expect.objectContaining({ id: b.id, name: 'b', deletedAt: null }), + ]); + await expect(db.item.findUnique({ where: { id: a.id } })).resolves.toBeNull(); + + // physically present, just marked deleted (peek via the plugin-less client) + const row = await raw.item.findUniqueOrThrow({ where: { id: a.id } }); + expect(row.deletedAt).not.toBeNull(); + }); + + it('soft-deletes a delegate model marked on the base', async () => { + // The @deletedAt marker lives on the polymorphic base. Deleting through either the base or a + // concrete model should soft-delete the base row, and reads through both should hide it. + const delegateSchema = ` +model Content { + id Int @id @default(autoincrement()) + contentType String + deletedAt DateTime? @deletedAt + @@delegate(contentType) +} + +model Article extends Content { + title String +} +`; + const raw = await createSoftDeleteTestClient(delegateSchema, { usePrismaPush: true }); + const db = raw.$use(new SoftDeletePlugin()); + + const a = await db.article.create({ data: { title: 'a' } }); + const b = await db.article.create({ data: { title: 'b' } }); + + // delete through the concrete model + await db.article.delete({ where: { id: a.id } }); + + // hidden from reads through both the concrete and base accessors + await expect(db.article.findUnique({ where: { id: a.id } })).resolves.toBeNull(); + await expect(db.content.findUnique({ where: { id: a.id } })).resolves.toBeNull(); + await expect(db.article.findMany()).resolves.toEqual([expect.objectContaining({ id: b.id, title: 'b' })]); + + // the base row is physically present, just marked deleted (the concrete row survives too) + const baseRow = await raw.content.findUniqueOrThrow({ where: { id: a.id } }); + expect(baseRow.deletedAt).not.toBeNull(); + await expect(raw.article.findUnique({ where: { id: a.id } })).resolves.not.toBeNull(); + + // delete through the base model soft-deletes as well + await db.content.delete({ where: { id: b.id } }); + await expect(db.article.findUnique({ where: { id: b.id } })).resolves.toBeNull(); + const baseRowB = await raw.content.findUniqueOrThrow({ where: { id: b.id } }); + expect(baseRowB.deletedAt).not.toBeNull(); + }); + + it('does not update an already soft-deleted delegate row', async () => { + // The marker lives on the base, but updates target the concrete sub-table. Updating a + // concrete-only field of a soft-deleted row must still be a no-op. The ORM funnels every + // delegate update through an `id IN ()` subquery that inherits the soft-delete read + // filter, so the soft-deleted row is invisible to the update. + const delegateSchema = ` +model Content { + id Int @id @default(autoincrement()) + contentType String + deletedAt DateTime? @deletedAt + @@delegate(contentType) +} + +model Article extends Content { + title String +} +`; + const raw = await createSoftDeleteTestClient(delegateSchema, { usePrismaPush: true }); + const db = raw.$use(new SoftDeletePlugin()); + + const a = await db.article.create({ data: { title: 'a' } }); + await db.article.delete({ where: { id: a.id } }); + + // updateMany on the concrete field skips the soft-deleted row and reports a zero count + await expect(db.article.updateMany({ where: { id: a.id }, data: { title: 'changed' } })).resolves.toEqual({ + count: 0, + }); + + // a single update can't find the soft-deleted row at all + await expect(db.article.update({ where: { id: a.id }, data: { title: 'changed' } })).rejects.toThrow( + /not found/i, + ); + + // the concrete row is physically untouched (peek via the plugin-less client) + const row = await raw.article.findUniqueOrThrow({ where: { id: a.id } }); + expect(row.title).toBe('a'); + }); });