From 2f30bdd8b17249073cee2344b73197866510b52c Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 9 Jul 2026 16:08:07 -0700 Subject: [PATCH] feat: accept array of parent entity classes in AllowIfInParentCascadeDeletionPrivacyPolicyRule --- ...nParentCascadeDeletionPrivacyPolicyRule.ts | 36 ++++--- ...ntCascadeDeletionPrivacyPolicyRule-test.ts | 102 ++++++++++++++++++ 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/packages/entity/src/rules/AllowIfInParentCascadeDeletionPrivacyPolicyRule.ts b/packages/entity/src/rules/AllowIfInParentCascadeDeletionPrivacyPolicyRule.ts index a8f640145a..ff4f43ef01 100644 --- a/packages/entity/src/rules/AllowIfInParentCascadeDeletionPrivacyPolicyRule.ts +++ b/packages/entity/src/rules/AllowIfInParentCascadeDeletionPrivacyPolicyRule.ts @@ -33,17 +33,27 @@ export interface AllowIfInParentCascadeDeletionDirective< TParentSelectedFields extends keyof TParentFields = keyof TParentFields, > { /** - * Class of parent entity that should trigger a cascade set null update to a field within - * the entity being authorized. + * Class (or classes) of parent entity that should trigger a cascade set null update to a field + * within the entity being authorized. Pass an array when multiple sibling classes backed by the + * same table can be the deletion source. */ - parentEntityClass: IEntityClass< - TParentFields, - TParentIDField, - TViewerContext, - TParentEntity, - TParentPrivacyPolicy, - TParentSelectedFields - >; + parentEntityClass: + | IEntityClass< + TParentFields, + TParentIDField, + TViewerContext, + TParentEntity, + TParentPrivacyPolicy, + TParentSelectedFields + > + | readonly IEntityClass< + TParentFields, + TParentIDField, + TViewerContext, + TParentEntity, + TParentPrivacyPolicy, + TParentSelectedFields + >[]; /** * Field of the current entity with references the deleting instace of parentEntityClass. @@ -138,10 +148,12 @@ export class AllowIfInParentCascadeDeletionPrivacyPolicyRule< >, entity: TEntity, ): Promise { - const parentEntityClass = this.directive.parentEntityClass; + const parentEntityClasses = Array.isArray(this.directive.parentEntityClass) + ? this.directive.parentEntityClass + : [this.directive.parentEntityClass]; const deleteCause = evaluationContext.cascadingDeleteCause; - if (!deleteCause || !(deleteCause.entity instanceof parentEntityClass)) { + if (!deleteCause || !parentEntityClasses.some((cls) => deleteCause.entity instanceof cls)) { return RuleEvaluationResult.SKIP; } diff --git a/packages/entity/src/rules/__tests__/AllowIfInParentCascadeDeletionPrivacyPolicyRule-test.ts b/packages/entity/src/rules/__tests__/AllowIfInParentCascadeDeletionPrivacyPolicyRule-test.ts index a1233bd82d..81fcb6be76 100644 --- a/packages/entity/src/rules/__tests__/AllowIfInParentCascadeDeletionPrivacyPolicyRule-test.ts +++ b/packages/entity/src/rules/__tests__/AllowIfInParentCascadeDeletionPrivacyPolicyRule-test.ts @@ -206,6 +206,108 @@ describePrivacyPolicyRule( }, ); +// Test with array of parent entity classes +class TestSiblingParentEntity extends ReadonlyEntity { + static defineCompanionDefinition(): EntityCompanionDefinition< + ParentFields, + 'id', + ViewerContext, + TestSiblingParentEntity, + TestParentPrivacyPolicy + > { + throw new Error('Not implemented for test'); + } +} + +const siblingParentEntityMock = mock(TestSiblingParentEntity); +when(siblingParentEntityMock.getID()).thenReturn('5'); +const siblingParentEntity = instance(siblingParentEntityMock); +Object.setPrototypeOf(siblingParentEntity, TestSiblingParentEntity.prototype); + +const childEntityForSiblingMock = mock(TestChildEntity); +when(childEntityForSiblingMock.getField('parent_id')).thenReturn('5'); + +const childEntityForSiblingNullifiedMock = mock(TestChildEntity); +when(childEntityForSiblingNullifiedMock.getField('parent_id')).thenReturn(null); + +describePrivacyPolicyRule( + new AllowIfInParentCascadeDeletionPrivacyPolicyRule< + ChildFields, + 'id', + ViewerContext, + TestChildEntity, + ParentFields, + 'id', + TestParentEntity, + TestParentPrivacyPolicy + >({ + fieldIdentifyingParentEntity: 'parent_id', + parentEntityClass: [TestParentEntity, TestSiblingParentEntity], + }), + { + allowCases: [ + // first class in array: parent id matches, field not yet nullified + { + viewerContext: instance(mock(ViewerContext)), + queryContext: instance(mock(EntityQueryContext)), + evaluationContext: { + action: anything(), + previousValue: instance(childEntityMock), + cascadingDeleteCause: { + entity: parentEntity, + cascadingDeleteCause: null, + }, + }, + entity: instance(childEntityMock), + }, + // second class in array: sibling parent id matches, field not yet nullified + { + viewerContext: instance(mock(ViewerContext)), + queryContext: instance(mock(EntityQueryContext)), + evaluationContext: { + action: anything(), + previousValue: instance(childEntityForSiblingMock), + cascadingDeleteCause: { + entity: siblingParentEntity, + cascadingDeleteCause: null, + }, + }, + entity: instance(childEntityForSiblingMock), + }, + // second class in array: field nullified, previous value matches + { + viewerContext: instance(mock(ViewerContext)), + queryContext: instance(mock(EntityQueryContext)), + evaluationContext: { + action: anything(), + previousValue: instance(childEntityForSiblingMock), + cascadingDeleteCause: { + entity: siblingParentEntity, + cascadingDeleteCause: null, + }, + }, + entity: instance(childEntityForSiblingNullifiedMock), + }, + ], + skipCases: [ + // unrelated entity class not in array + { + viewerContext: instance(mock(ViewerContext)), + queryContext: instance(mock(EntityQueryContext)), + evaluationContext: { + action: anything(), + previousValue: null, + cascadingDeleteCause: { + entity: instance(unrelatedOtherEntityMock), + cascadingDeleteCause: null, + }, + }, + entity: instance(childEntityMock), + }, + ], + }, +); + // Test with custom lookup field (parentEntityLookupByField) const parentEntityWithNameMock = mock(TestParentEntity); when(parentEntityWithNameMock.getField('name')).thenReturn('test-name');