Skip to content
Merged
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
53 changes: 49 additions & 4 deletions packages/plugins/soft-delete/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ class SoftDeleteHandler<Schema extends SchemaDef> 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;
Expand Down Expand Up @@ -183,18 +193,43 @@ class SoftDeleteHandler<Schema extends SchemaDef> 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;
}
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)),
Expand Down Expand Up @@ -239,6 +274,16 @@ class SoftDeleteHandler<Schema extends SchemaDef> 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
// `<SubModel>.<deletedAt>` 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.
Expand Down
115 changes: 113 additions & 2 deletions packages/plugins/soft-delete/test/soft-delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) {
return createTestClient(schema, { extraPluginModelFiles: [PLUGIN_MODEL_FILE], ...extraOptions });
}

const schema = `
Expand Down Expand Up @@ -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 (<read>)` 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');
});
});
Loading