diff --git a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts index 7f0caae5..b542f561 100644 --- a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts +++ b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts @@ -8,6 +8,7 @@ import { enforceAsyncResult } from '@expo/results'; import Knex from 'knex'; import PostgresTestEntity from '../testfixtures/PostgresTestEntity'; +import PostgresTriggerTestEntity from '../testfixtures/PostgresTriggerTestEntity'; import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider'; describe('postgres entity integration', () => { @@ -325,4 +326,164 @@ describe('postgres entity integration', () => { expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']); }); }); + + describe('trigger transaction behavior', () => { + describe('create', () => { + it('rolls back transaction when trigger throws except afterCommit', async () => { + const vc1 = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance) + ); + + await expect( + PostgresTriggerTestEntity.creator(vc1) + .setField('name', 'beforeCreate') + .enforceCreateAsync() + ).rejects.toThrowError('name cannot have value beforeCreate'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'beforeCreate') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.creator(vc1) + .setField('name', 'afterCreate') + .enforceCreateAsync() + ).rejects.toThrowError('name cannot have value afterCreate'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'afterCreate') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.creator(vc1).setField('name', 'beforeAll').enforceCreateAsync() + ).rejects.toThrowError('name cannot have value beforeAll'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'beforeAll') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.creator(vc1).setField('name', 'afterAll').enforceCreateAsync() + ).rejects.toThrowError('name cannot have value afterAll'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'afterAll') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.creator(vc1) + .setField('name', 'afterCommit') + .enforceCreateAsync() + ).rejects.toThrowError('name cannot have value afterCommit'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'afterCommit') + ).resolves.not.toBeNull(); + }); + }); + + describe('update', () => { + it('rolls back transaction when trigger throws except afterCommit', async () => { + const vc1 = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance) + ); + + const entity = await PostgresTriggerTestEntity.creator(vc1) + .setField('name', 'blah') + .enforceCreateAsync(); + + await expect( + PostgresTriggerTestEntity.updater(entity) + .setField('name', 'beforeUpdate') + .enforceUpdateAsync() + ).rejects.toThrowError('name cannot have value beforeUpdate'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'beforeUpdate') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.updater(entity) + .setField('name', 'afterUpdate') + .enforceUpdateAsync() + ).rejects.toThrowError('name cannot have value afterUpdate'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'afterUpdate') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.updater(entity) + .setField('name', 'beforeAll') + .enforceUpdateAsync() + ).rejects.toThrowError('name cannot have value beforeAll'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'beforeAll') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.updater(entity) + .setField('name', 'afterAll') + .enforceUpdateAsync() + ).rejects.toThrowError('name cannot have value afterAll'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'afterAll') + ).resolves.toBeNull(); + + await expect( + PostgresTriggerTestEntity.updater(entity) + .setField('name', 'afterCommit') + .enforceUpdateAsync() + ).rejects.toThrowError('name cannot have value afterCommit'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'afterCommit') + ).resolves.not.toBeNull(); + }); + }); + + describe('delete', () => { + it('rolls back transaction when trigger throws except afterCommit', async () => { + const vc1 = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance) + ); + + const entityBeforeDelete = await PostgresTriggerTestEntity.creator(vc1) + .setField('name', 'beforeDelete') + .enforceCreateAsync(); + await expect( + PostgresTriggerTestEntity.enforceDeleteAsync(entityBeforeDelete) + ).rejects.toThrowError('name cannot have value beforeDelete'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'beforeDelete') + ).resolves.not.toBeNull(); + + const entityAfterDelete = await PostgresTriggerTestEntity.creator(vc1) + .setField('name', 'afterDelete') + .enforceCreateAsync(); + await expect( + PostgresTriggerTestEntity.enforceDeleteAsync(entityAfterDelete) + ).rejects.toThrowError('name cannot have value afterDelete'); + await expect( + PostgresTriggerTestEntity.loader(vc1) + .enforcing() + .loadByFieldEqualingAsync('name', 'afterDelete') + ).resolves.not.toBeNull(); + }); + }); + }); }); diff --git a/packages/entity-database-adapter-knex/src/testfixtures/PostgresTriggerTestEntity.ts b/packages/entity-database-adapter-knex/src/testfixtures/PostgresTriggerTestEntity.ts new file mode 100644 index 00000000..95ed5aec --- /dev/null +++ b/packages/entity-database-adapter-knex/src/testfixtures/PostgresTriggerTestEntity.ts @@ -0,0 +1,154 @@ +import { + AlwaysAllowPrivacyPolicyRule, + EntityPrivacyPolicy, + ViewerContext, + UUIDField, + StringField, + EntityConfiguration, + DatabaseAdapterFlavor, + CacheAdapterFlavor, + EntityCompanionDefinition, + Entity, + EntityMutationTrigger, + EntityQueryContext, +} from '@expo/entity'; +import Knex from 'knex'; + +type PostgresTriggerTestEntityFields = { + id: string; + name: string | null; +}; + +export default class PostgresTriggerTestEntity extends Entity< + PostgresTriggerTestEntityFields, + string, + ViewerContext +> { + static getCompanionDefinition(): EntityCompanionDefinition< + PostgresTriggerTestEntityFields, + string, + ViewerContext, + PostgresTriggerTestEntity, + PostgresTriggerTestEntityPrivacyPolicy + > { + return postgresTestEntityCompanionDefinition; + } + + public static async createOrTruncatePostgresTable(knex: Knex): Promise { + await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4() + + const tableName = this.getCompanionDefinition().entityConfiguration.tableName; + const hasTable = await knex.schema.hasTable(tableName); + if (!hasTable) { + await knex.schema.createTable(tableName, (table) => { + table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); + table.string('name'); + }); + } + await knex.into(tableName).truncate(); + } + + public static async dropPostgresTable(knex: Knex): Promise { + const tableName = this.getCompanionDefinition().entityConfiguration.tableName; + const hasTable = await knex.schema.hasTable(tableName); + if (hasTable) { + await knex.schema.dropTable(tableName); + } + } +} + +class PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy< + PostgresTriggerTestEntityFields, + string, + ViewerContext, + PostgresTriggerTestEntity +> { + protected readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule< + PostgresTriggerTestEntityFields, + string, + ViewerContext, + PostgresTriggerTestEntity + >(), + ]; + protected readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule< + PostgresTriggerTestEntityFields, + string, + ViewerContext, + PostgresTriggerTestEntity + >(), + ]; + protected readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule< + PostgresTriggerTestEntityFields, + string, + ViewerContext, + PostgresTriggerTestEntity + >(), + ]; + protected readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule< + PostgresTriggerTestEntityFields, + string, + ViewerContext, + PostgresTriggerTestEntity + >(), + ]; +} + +class ThrowConditionallyTrigger extends EntityMutationTrigger< + PostgresTriggerTestEntityFields, + string, + ViewerContext, + PostgresTriggerTestEntity +> { + constructor(private fieldName: keyof PostgresTriggerTestEntityFields, private badValue: string) { + super(); + } + + async executeAsync( + _viewerContext: ViewerContext, + _queryContext: EntityQueryContext, + entity: PostgresTriggerTestEntity + ): Promise { + if (entity.getField(this.fieldName) === this.badValue) { + throw new Error(`${this.fieldName} cannot have value ${this.badValue}`); + } + } +} + +export const postgresTestEntityConfiguration = new EntityConfiguration< + PostgresTriggerTestEntityFields +>({ + idField: 'id', + tableName: 'postgres_test_entities', + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + name: new StringField({ + columnName: 'name', + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, +}); + +const postgresTestEntityCompanionDefinition = new EntityCompanionDefinition({ + entityClass: PostgresTriggerTestEntity, + entityConfiguration: postgresTestEntityConfiguration, + privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy, + mutationTriggers: { + beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')], + afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')], + beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')], + afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')], + beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')], + afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')], + beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')], + afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')], + afterCommit: [new ThrowConditionallyTrigger('name', 'afterCommit')], + }, +}); diff --git a/packages/entity/src/EntityCompanion.ts b/packages/entity/src/EntityCompanion.ts index f21a7887..edf348e7 100644 --- a/packages/entity/src/EntityCompanion.ts +++ b/packages/entity/src/EntityCompanion.ts @@ -1,5 +1,6 @@ import { IEntityClass } from './Entity'; import EntityLoaderFactory from './EntityLoaderFactory'; +import { EntityMutationTriggerConfiguration } from './EntityMutationTrigger'; import EntityMutatorFactory from './EntityMutatorFactory'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import IEntityQueryContextProvider from './IEntityQueryContextProvider'; @@ -57,6 +58,13 @@ export default class EntityCompanion< >, private readonly tableDataCoordinator: EntityTableDataCoordinator, PrivacyPolicyClass: IPrivacyPolicyClass, + mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, metricsAdapter: IEntityMetricsAdapter ) { const privacyPolicy = new PrivacyPolicyClass(); @@ -70,6 +78,7 @@ export default class EntityCompanion< tableDataCoordinator.entityConfiguration, entityClass, privacyPolicy, + mutationTriggers, this.entityLoaderFactory, tableDataCoordinator.databaseAdapter, metricsAdapter diff --git a/packages/entity/src/EntityCompanionProvider.ts b/packages/entity/src/EntityCompanionProvider.ts index 06cc6c76..7325e15a 100644 --- a/packages/entity/src/EntityCompanionProvider.ts +++ b/packages/entity/src/EntityCompanionProvider.ts @@ -1,6 +1,7 @@ import { IEntityClass } from './Entity'; import EntityCompanion, { IPrivacyPolicyClass } from './EntityCompanion'; import EntityConfiguration from './EntityConfiguration'; +import { EntityMutationTriggerConfiguration } from './EntityMutationTrigger'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import IEntityCacheAdapterProvider from './IEntityCacheAdapterProvider'; import IEntityDatabaseAdapterProvider from './IEntityDatabaseAdapterProvider'; @@ -80,12 +81,20 @@ export class EntityCompanionDefinition< >; readonly entityConfiguration: EntityConfiguration; readonly privacyPolicyClass: IPrivacyPolicyClass; + readonly mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >; readonly entitySelectedFields: TSelectedFields[]; constructor({ entityClass, entityConfiguration, privacyPolicyClass, + mutationTriggers = {}, entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[], }: { entityClass: IEntityClass< @@ -98,11 +107,19 @@ export class EntityCompanionDefinition< >; entityConfiguration: EntityConfiguration; privacyPolicyClass: IPrivacyPolicyClass; + mutationTriggers?: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >; entitySelectedFields?: TSelectedFields[]; }) { this.entityClass = entityClass; this.entityConfiguration = entityConfiguration; this.privacyPolicyClass = privacyPolicyClass; + this.mutationTriggers = mutationTriggers; this.entitySelectedFields = entitySelectedFields; } } @@ -184,6 +201,7 @@ export default class EntityCompanionProvider { entityCompanionDefinition.entityClass, tableDataCoordinator, entityCompanionDefinition.privacyPolicyClass, + entityCompanionDefinition.mutationTriggers, this.metricsAdapter ); }); diff --git a/packages/entity/src/EntityMutationTrigger.ts b/packages/entity/src/EntityMutationTrigger.ts new file mode 100644 index 00000000..4bad44a3 --- /dev/null +++ b/packages/entity/src/EntityMutationTrigger.ts @@ -0,0 +1,77 @@ +import { EntityQueryContext } from './EntityQueryContext'; +import ReadonlyEntity from './ReadonlyEntity'; +import ViewerContext from './ViewerContext'; + +/** + * Interface to define trigger behavior for entities. + */ +export interface EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TSelectedFields extends keyof TFields = keyof TFields +> { + /** + * Trigger set that runs within the transaction but before the entity is created in the database. + */ + beforeCreate?: EntityMutationTrigger[]; + /** + * Trigger set that runs within the transaction but after the entity is created in the database and cache is invalidated. + */ + afterCreate?: EntityMutationTrigger[]; + + /** + * Trigger set that runs within the transaction but before the entity is updated in the database. + */ + beforeUpdate?: EntityMutationTrigger[]; + /** + * Trigger set that runs within the transaction but after the entity is updated in the database and cache is invalidated. + */ + afterUpdate?: EntityMutationTrigger[]; + + /** + * Trigger set that runs within the transaction but before the entity is deleted from the database. + */ + beforeDelete?: EntityMutationTrigger[]; + /** + * Trigger set that runs within the transaction but after the entity is deleted from the database and cache is invalidated. + */ + afterDelete?: EntityMutationTrigger[]; + + /** + * Trigger set that runs within the transaction but before the entity is created, updated, or deleted. + */ + beforeAll?: EntityMutationTrigger[]; + /** + * Trigger set that runs within the transaction but before the entity is created in, updated in, or deleted from + * the database and the cache is invalidated. + */ + afterAll?: EntityMutationTrigger[]; + + /** + * Trigger set that runs after committing the transaction unless one is supplied + * after any mutation (create, update, delete). If the call to the mutation is wrapped in a transaction, + * this too will be within the transaction. + */ + afterCommit?: EntityMutationTrigger[]; +} + +/** + * A trigger is a way to specify entity mutation operation side-effects that run within the + * same transaction as the mutation itself. The one exception is afterCommit, which will run within + * the transaction if a transaction is supplied. + */ +export abstract class EntityMutationTrigger< + TFields, + TID, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TSelectedFields extends keyof TFields = keyof TFields +> { + abstract async executeAsync( + viewerContext: TViewerContext, + queryContext: EntityQueryContext, + entity: TEntity + ): Promise; +} diff --git a/packages/entity/src/EntityMutator.ts b/packages/entity/src/EntityMutator.ts index 2899d8c1..763a6c67 100644 --- a/packages/entity/src/EntityMutator.ts +++ b/packages/entity/src/EntityMutator.ts @@ -6,8 +6,9 @@ import EntityConfiguration from './EntityConfiguration'; import EntityDatabaseAdapter from './EntityDatabaseAdapter'; import { EntityEdgeDeletionBehavior } from './EntityFields'; import EntityLoaderFactory from './EntityLoaderFactory'; +import { EntityMutationTriggerConfiguration, EntityMutationTrigger } from './EntityMutationTrigger'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; -import { EntityQueryContext } from './EntityQueryContext'; +import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; import ReadonlyEntity from './ReadonlyEntity'; import ViewerContext from './ViewerContext'; import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils'; @@ -41,6 +42,13 @@ abstract class BaseMutator< TSelectedFields >, protected readonly privacyPolicy: TPrivacyPolicy, + protected readonly mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, protected readonly entityLoaderFactory: EntityLoaderFactory< TFields, TID, @@ -52,6 +60,21 @@ abstract class BaseMutator< protected readonly databaseAdapter: EntityDatabaseAdapter, protected readonly metricsAdapter: IEntityMetricsAdapter ) {} + + protected async executeTriggers( + triggers: + | EntityMutationTrigger[] + | undefined, + queryContext: EntityQueryContext, + entity: TEntity + ): Promise { + if (!triggers) { + return; + } + await Promise.all( + triggers.map((trigger) => trigger.executeAsync(this.viewerContext, queryContext, entity)) + ); + } } /** @@ -103,12 +126,22 @@ export class CreateMutator< } private async createInTransactionAsync(): Promise> { - return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => - this.createInternalAsync(innerQueryContext) + const internalResult = await this.queryContext.runInTransactionIfNotInTransactionAsync( + (innerQueryContext) => this.createInternalAsync(innerQueryContext) ); + if (internalResult.ok) { + await this.executeTriggers( + this.mutationTriggers.afterCommit, + this.queryContext, + internalResult.value + ); + } + return internalResult; } - private async createInternalAsync(queryContext: EntityQueryContext): Promise> { + private async createInternalAsync( + queryContext: EntityTransactionalQueryContext + ): Promise> { const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({ [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID ...this.fieldsForEntity, @@ -125,13 +158,31 @@ export class CreateMutator< return authorizeCreateResult; } + await this.executeTriggers( + this.mutationTriggers.beforeAll, + queryContext, + temporaryEntityForPrivacyCheck + ); + await this.executeTriggers( + this.mutationTriggers.beforeCreate, + queryContext, + temporaryEntityForPrivacyCheck + ); + const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); await entityLoader.invalidateFieldsAsync(insertResult); const unauthorizedEntityAfterInsert = new this.entityClass(this.viewerContext, insertResult); - return await entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID()); + const newEntity = await entityLoader + .enforcing() + .loadByIDAsync(unauthorizedEntityAfterInsert.getID()); + + await this.executeTriggers(this.mutationTriggers.afterCreate, queryContext, newEntity); + await this.executeTriggers(this.mutationTriggers.afterAll, queryContext, newEntity); + + return result(newEntity); } } @@ -169,6 +220,13 @@ export class UpdateMutator< TSelectedFields >, privacyPolicy: TPrivacyPolicy, + mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, entityLoaderFactory: EntityLoaderFactory< TFields, TID, @@ -187,6 +245,7 @@ export class UpdateMutator< entityConfiguration, entityClass, privacyPolicy, + mutationTriggers, entityLoaderFactory, databaseAdapter, metricsAdapter @@ -227,12 +286,22 @@ export class UpdateMutator< } private async updateInTransactionAsync(): Promise> { - return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => - this.updateInternalAsync(innerQueryContext) + const internalResult = await this.queryContext.runInTransactionIfNotInTransactionAsync( + (innerQueryContext) => this.updateInternalAsync(innerQueryContext) ); + if (internalResult.ok) { + await this.executeTriggers( + this.mutationTriggers.afterCommit, + this.queryContext, + internalResult.value + ); + } + return internalResult; } - private async updateInternalAsync(queryContext: EntityQueryContext): Promise> { + private async updateInternalAsync( + queryContext: EntityTransactionalQueryContext + ): Promise> { const entityAboutToBeUpdated = new this.entityClass(this.viewerContext, this.fieldsForEntity); const authorizeUpdateResult = await asyncResult( this.privacyPolicy.authorizeUpdateAsync( @@ -245,6 +314,17 @@ export class UpdateMutator< return authorizeUpdateResult; } + await this.executeTriggers( + this.mutationTriggers.beforeAll, + queryContext, + entityAboutToBeUpdated + ); + await this.executeTriggers( + this.mutationTriggers.beforeUpdate, + queryContext, + entityAboutToBeUpdated + ); + const updateResult = await this.databaseAdapter.updateAsync( queryContext, this.entityConfiguration.idField, @@ -258,7 +338,14 @@ export class UpdateMutator< await entityLoader.invalidateFieldsAsync(this.fieldsForEntity); const unauthorizedEntityAfterUpdate = new this.entityClass(this.viewerContext, updateResult); - return await entityLoader.loadByIDAsync(unauthorizedEntityAfterUpdate.getID()); + const updatedEntity = await entityLoader + .enforcing() + .loadByIDAsync(unauthorizedEntityAfterUpdate.getID()); + + await this.executeTriggers(this.mutationTriggers.afterUpdate, queryContext, updatedEntity); + await this.executeTriggers(this.mutationTriggers.afterAll, queryContext, updatedEntity); + + return result(updatedEntity); } } @@ -292,6 +379,13 @@ export class DeleteMutator< TSelectedFields >, privacyPolicy: TPrivacyPolicy, + mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, entityLoaderFactory: EntityLoaderFactory< TFields, TID, @@ -310,6 +404,7 @@ export class DeleteMutator< entityConfiguration, entityClass, privacyPolicy, + mutationTriggers, entityLoaderFactory, databaseAdapter, metricsAdapter @@ -335,15 +430,23 @@ export class DeleteMutator< } private async deleteInTransactionAsync(): Promise> { - return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) => - this.deleteInternalAsync(innerQueryContext) + const internalResult = await this.queryContext.runInTransactionIfNotInTransactionAsync( + (innerQueryContext) => this.deleteInternalAsync(innerQueryContext) ); + if (internalResult.ok) { + await this.executeTriggers( + this.mutationTriggers.afterCommit, + this.queryContext, + internalResult.value + ); + } + return internalResult.ok ? result() : result(internalResult.reason); } private async deleteInternalAsync( - queryContext: EntityQueryContext, + queryContext: EntityTransactionalQueryContext, processedEntityIdentifiersFromTransitiveDeletions: Set = new Set() - ): Promise> { + ): Promise> { const authorizeDeleteResult = await asyncResult( this.privacyPolicy.authorizeDeleteAsync(this.viewerContext, queryContext, this.entity) ); @@ -357,6 +460,9 @@ export class DeleteMutator< processedEntityIdentifiersFromTransitiveDeletions ); + await this.executeTriggers(this.mutationTriggers.beforeAll, queryContext, this.entity); + await this.executeTriggers(this.mutationTriggers.beforeDelete, queryContext, this.entity); + await this.databaseAdapter.deleteAsync( queryContext, this.entityConfiguration.idField, @@ -366,7 +472,10 @@ export class DeleteMutator< const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext); await entityLoader.invalidateFieldsAsync(this.entity.getAllDatabaseFields()); - return result(); + await this.executeTriggers(this.mutationTriggers.afterDelete, queryContext, this.entity); + await this.executeTriggers(this.mutationTriggers.afterAll, queryContext, this.entity); + + return result(this.entity); } /** @@ -391,7 +500,7 @@ export class DeleteMutator< TMSelectedFields extends keyof TMFields >( entity: TMEntity, - queryContext: EntityQueryContext, + queryContext: EntityTransactionalQueryContext, processedEntityIdentifiers: Set ): Promise { // prevent infinite reference cycles by keeping track of entities already processed diff --git a/packages/entity/src/EntityMutatorFactory.ts b/packages/entity/src/EntityMutatorFactory.ts index 956d7588..9694ce0e 100644 --- a/packages/entity/src/EntityMutatorFactory.ts +++ b/packages/entity/src/EntityMutatorFactory.ts @@ -2,6 +2,7 @@ import Entity, { IEntityClass } from './Entity'; import EntityConfiguration from './EntityConfiguration'; import EntityDatabaseAdapter from './EntityDatabaseAdapter'; import EntityLoaderFactory from './EntityLoaderFactory'; +import { EntityMutationTriggerConfiguration } from './EntityMutationTrigger'; import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; @@ -36,6 +37,13 @@ export default class EntityMutatorFactory< TSelectedFields >, private readonly privacyPolicy: TPrivacyPolicy, + private readonly mutationTriggers: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, private readonly entityLoaderFactory: EntityLoaderFactory< TFields, TID, @@ -64,6 +72,7 @@ export default class EntityMutatorFactory< this.entityConfiguration, this.entityClass, this.privacyPolicy, + this.mutationTriggers, this.entityLoaderFactory, this.databaseAdapter, this.metricsAdapter @@ -86,6 +95,7 @@ export default class EntityMutatorFactory< this.entityConfiguration, this.entityClass, this.privacyPolicy, + this.mutationTriggers, this.entityLoaderFactory, this.databaseAdapter, this.metricsAdapter, @@ -108,6 +118,7 @@ export default class EntityMutatorFactory< this.entityConfiguration, this.entityClass, this.privacyPolicy, + this.mutationTriggers, this.entityLoaderFactory, this.databaseAdapter, this.metricsAdapter, diff --git a/packages/entity/src/EntityQueryContext.ts b/packages/entity/src/EntityQueryContext.ts index 87883a0a..b98a81ac 100644 --- a/packages/entity/src/EntityQueryContext.ts +++ b/packages/entity/src/EntityQueryContext.ts @@ -17,7 +17,7 @@ export abstract class EntityQueryContext { } abstract async runInTransactionIfNotInTransactionAsync( - transactionScope: (queryContext: EntityQueryContext) => Promise + transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise ): Promise; } diff --git a/packages/entity/src/__tests__/EntityCompanion-test.ts b/packages/entity/src/__tests__/EntityCompanion-test.ts index dd0875c0..5aafd716 100644 --- a/packages/entity/src/__tests__/EntityCompanion-test.ts +++ b/packages/entity/src/__tests__/EntityCompanion-test.ts @@ -19,6 +19,7 @@ describe(EntityCompanion, () => { TestEntity, instance(tableDataCoordinatorMock), TestEntityPrivacyPolicy, + {}, instance(mock()) ); expect(companion.getLoaderFactory()).toBeInstanceOf(EntityLoaderFactory); diff --git a/packages/entity/src/__tests__/EntityMutator-test.ts b/packages/entity/src/__tests__/EntityMutator-test.ts index 5f20943a..5e4bd199 100644 --- a/packages/entity/src/__tests__/EntityMutator-test.ts +++ b/packages/entity/src/__tests__/EntityMutator-test.ts @@ -12,8 +12,16 @@ import { import EntityDatabaseAdapter from '../EntityDatabaseAdapter'; import EntityLoaderFactory from '../EntityLoaderFactory'; +import { + EntityMutationTriggerConfiguration, + EntityMutationTrigger, +} from '../EntityMutationTrigger'; import EntityMutatorFactory from '../EntityMutatorFactory'; -import { EntityTransactionalQueryContext } from '../EntityQueryContext'; +import { + EntityTransactionalQueryContext, + EntityQueryContext, + EntityNonTransactionalQueryContext, +} from '../EntityQueryContext'; import ViewerContext from '../ViewerContext'; import { enforceResultsAsync } from '../entityUtils'; import EntityDataManager from '../internal/EntityDataManager'; @@ -34,6 +42,121 @@ import { NoCacheStubCacheAdapterProvider } from '../utils/testing/StubCacheAdapt import StubDatabaseAdapter from '../utils/testing/StubDatabaseAdapter'; import StubQueryContextProvider from '../utils/testing/StubQueryContextProvider'; +class TestMutationTrigger extends EntityMutationTrigger< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields +> { + async executeAsync( + _viewerContext: ViewerContext, + _queryContext: EntityQueryContext, + _entity: TestEntity + ): Promise {} +} + +const setUpMutationTriggerSpies = ( + mutationTriggers: EntityMutationTriggerConfiguration< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + > +): EntityMutationTriggerConfiguration< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields +> => { + return { + beforeCreate: [spy(mutationTriggers.beforeCreate![0])], + afterCreate: [spy(mutationTriggers.afterCreate![0])], + beforeUpdate: [spy(mutationTriggers.beforeUpdate![0])], + afterUpdate: [spy(mutationTriggers.afterUpdate![0])], + beforeDelete: [spy(mutationTriggers.beforeDelete![0])], + afterDelete: [spy(mutationTriggers.afterDelete![0])], + beforeAll: [spy(mutationTriggers.beforeAll![0])], + afterAll: [spy(mutationTriggers.afterAll![0])], + afterCommit: [spy(mutationTriggers.afterCommit![0])], + }; +}; + +const verifyTriggerCounts = ( + viewerContext: ViewerContext, + mutationTriggerSpies: EntityMutationTriggerConfiguration< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + >, + executed: Record< + keyof Pick< + EntityMutationTriggerConfiguration< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + >, + | 'beforeCreate' + | 'afterCreate' + | 'beforeUpdate' + | 'afterUpdate' + | 'beforeDelete' + | 'afterDelete' + >, + boolean + > +): void => { + Object.keys(executed).forEach((s) => { + if ((executed as any)[s]) { + verify( + (mutationTriggerSpies as any)[s]![0].executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).once(); + } else { + verify( + (mutationTriggerSpies as any)[s]![0].executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).never(); + } + }); + + verify( + mutationTriggerSpies.beforeAll![0].executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).once(); + + verify( + mutationTriggerSpies.afterAll![0].executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).once(); + + verify( + mutationTriggerSpies.afterCommit![0].executeAsync( + viewerContext, + anyOfClass(EntityNonTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).once(); +}; + const createEntityMutatorFactory = ( existingObjects: TestFields[] ): { @@ -54,7 +177,31 @@ const createEntityMutatorFactory = ( TestEntityPrivacyPolicy >; metricsAdapter: IEntityMetricsAdapter; + mutationTriggers: EntityMutationTriggerConfiguration< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + >; } => { + const mutationTriggers: EntityMutationTriggerConfiguration< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + > = { + beforeCreate: [new TestMutationTrigger()], + afterCreate: [new TestMutationTrigger()], + beforeUpdate: [new TestMutationTrigger()], + afterUpdate: [new TestMutationTrigger()], + beforeDelete: [new TestMutationTrigger()], + afterDelete: [new TestMutationTrigger()], + beforeAll: [new TestMutationTrigger()], + afterAll: [new TestMutationTrigger()], + afterCommit: [new TestMutationTrigger()], + }; const privacyPolicy = new TestEntityPrivacyPolicy(); const databaseAdapter = new StubDatabaseAdapter( testEntityConfiguration, @@ -83,6 +230,7 @@ const createEntityMutatorFactory = ( testEntityConfiguration, TestEntity, privacyPolicy, + mutationTriggers, entityLoaderFactory, databaseAdapter, metricsAdapter @@ -92,53 +240,117 @@ const createEntityMutatorFactory = ( entityLoaderFactory, entityMutatorFactory, metricsAdapter, + mutationTriggers, }; }; describe(EntityMutatorFactory, () => { - it('creates entities and checks privacy', async () => { - const viewerContext = mock(); - const queryContext = StubQueryContextProvider.getQueryContext(); - const { privacyPolicy, entityMutatorFactory } = createEntityMutatorFactory([ - { - customIdField: 'hello', - stringField: 'huh', - testIndexedField: '4', - numberField: 1, - dateField: new Date(), - }, - { - customIdField: 'world', - stringField: 'huh', - testIndexedField: '5', - numberField: 1, - dateField: new Date(), - }, - ]); + describe('forCreate', () => { + it('creates entities', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { entityMutatorFactory } = createEntityMutatorFactory([ + { + customIdField: 'hello', + stringField: 'huh', + testIndexedField: '4', + numberField: 1, + dateField: new Date(), + }, + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '5', + numberField: 1, + dateField: new Date(), + }, + ]); + const newEntity = await entityMutatorFactory + .forCreate(viewerContext, queryContext) + .setField('stringField', 'huh') + .enforceCreateAsync(); + expect(newEntity).toBeTruthy(); + }); - const spiedPrivacyPolicy = spy(privacyPolicy); + it('checks privacy', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { privacyPolicy, entityMutatorFactory } = createEntityMutatorFactory([ + { + customIdField: 'hello', + stringField: 'huh', + testIndexedField: '4', + numberField: 1, + dateField: new Date(), + }, + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '5', + numberField: 1, + dateField: new Date(), + }, + ]); - const newEntity = await entityMutatorFactory - .forCreate(viewerContext, queryContext) - .setField('stringField', 'huh') - .enforceCreateAsync(); + const spiedPrivacyPolicy = spy(privacyPolicy); - expect(newEntity).toBeTruthy(); + await entityMutatorFactory + .forCreate(viewerContext, queryContext) + .setField('stringField', 'huh') + .enforceCreateAsync(); + + verify( + spiedPrivacyPolicy.authorizeCreateAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).once(); + }); - verify( - spiedPrivacyPolicy.authorizeCreateAsync( - viewerContext, - anyOfClass(EntityTransactionalQueryContext), - anyOfClass(TestEntity) - ) - ).once(); + it('executes triggers', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { mutationTriggers, entityMutatorFactory } = createEntityMutatorFactory([ + { + customIdField: 'hello', + stringField: 'huh', + testIndexedField: '4', + numberField: 1, + dateField: new Date(), + }, + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '5', + numberField: 1, + dateField: new Date(), + }, + ]); + + const triggerSpies = setUpMutationTriggerSpies(mutationTriggers); + + await entityMutatorFactory + .forCreate(viewerContext, queryContext) + .setField('stringField', 'huh') + .enforceCreateAsync(); + + verifyTriggerCounts(viewerContext, triggerSpies, { + beforeCreate: true, + afterCreate: true, + beforeUpdate: false, + afterUpdate: false, + beforeDelete: false, + afterDelete: false, + }); + }); }); - it('updates entities and checks privacy', async () => { - const viewerContext = mock(); - const queryContext = StubQueryContextProvider.getQueryContext(); - const { privacyPolicy, entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory( - [ + describe('forUpdate', () => { + it('updates entities', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([ { customIdField: 'hello', stringField: 'huh', @@ -153,43 +365,122 @@ describe(EntityMutatorFactory, () => { numberField: 3, dateField: new Date(), }, - ] - ); + ]); - const spiedPrivacyPolicy = spy(privacyPolicy); + const existingEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); - const existingEntity = await enforceAsyncResult( - entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') - ); + const updatedEntity = await entityMutatorFactory + .forUpdate(existingEntity, queryContext) + .setField('stringField', 'huh2') + .enforceUpdateAsync(); - const updatedEntity = await entityMutatorFactory - .forUpdate(existingEntity, queryContext) - .setField('stringField', 'huh2') - .enforceUpdateAsync(); + expect(updatedEntity).toBeTruthy(); + expect(updatedEntity.getAllFields()).not.toMatchObject(existingEntity.getAllFields()); + expect(updatedEntity.getField('stringField')).toEqual('huh2'); - expect(updatedEntity).toBeTruthy(); - expect(updatedEntity.getAllFields()).not.toMatchObject(existingEntity.getAllFields()); - expect(updatedEntity.getField('stringField')).toEqual('huh2'); + const reloadedEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); + expect(reloadedEntity.getAllFields()).toMatchObject(updatedEntity.getAllFields()); + }); - const reloadedEntity = await enforceAsyncResult( - entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') - ); - expect(reloadedEntity.getAllFields()).toMatchObject(updatedEntity.getAllFields()); + it('checks privacy', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { + privacyPolicy, + entityMutatorFactory, + entityLoaderFactory, + } = createEntityMutatorFactory([ + { + customIdField: 'hello', + stringField: 'huh', + testIndexedField: '3', + numberField: 3, + dateField: new Date(), + }, + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '4', + numberField: 3, + dateField: new Date(), + }, + ]); - verify( - spiedPrivacyPolicy.authorizeUpdateAsync( - viewerContext, - anyOfClass(EntityTransactionalQueryContext), - anyOfClass(TestEntity) - ) - ).once(); + const spiedPrivacyPolicy = spy(privacyPolicy); + + const existingEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); + + await entityMutatorFactory + .forUpdate(existingEntity, queryContext) + .setField('stringField', 'huh2') + .enforceUpdateAsync(); + + verify( + spiedPrivacyPolicy.authorizeUpdateAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).once(); + }); + + it('executes triggers', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { + mutationTriggers, + entityMutatorFactory, + entityLoaderFactory, + } = createEntityMutatorFactory([ + { + customIdField: 'hello', + stringField: 'huh', + testIndexedField: '3', + numberField: 3, + dateField: new Date(), + }, + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '4', + numberField: 3, + dateField: new Date(), + }, + ]); + + const triggerSpies = setUpMutationTriggerSpies(mutationTriggers); + + const existingEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); + + await entityMutatorFactory + .forUpdate(existingEntity, queryContext) + .setField('stringField', 'huh2') + .enforceUpdateAsync(); + + verifyTriggerCounts(viewerContext, triggerSpies, { + beforeCreate: false, + afterCreate: false, + beforeUpdate: true, + afterUpdate: true, + beforeDelete: false, + afterDelete: false, + }); + }); }); - it('deletes entities and checks privacy', async () => { - const viewerContext = mock(); - const queryContext = StubQueryContextProvider.getQueryContext(); - const { privacyPolicy, entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory( - [ + describe('forDelete', () => { + it('deletes entities', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { entityMutatorFactory, entityLoaderFactory } = createEntityMutatorFactory([ { customIdField: 'world', stringField: 'huh', @@ -197,31 +488,90 @@ describe(EntityMutatorFactory, () => { numberField: 3, dateField: new Date(), }, - ] - ); + ]); - const spiedPrivacyPolicy = spy(privacyPolicy); + const existingEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); + expect(existingEntity).toBeTruthy(); - const existingEntity = await enforceAsyncResult( - entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') - ); - expect(existingEntity).toBeTruthy(); + await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); - await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); + await expect( + enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ) + ).rejects.toBeInstanceOf(Error); + }); - await expect( - enforceAsyncResult( + it('checks privacy', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { + privacyPolicy, + entityMutatorFactory, + entityLoaderFactory, + } = createEntityMutatorFactory([ + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '3', + numberField: 3, + dateField: new Date(), + }, + ]); + + const spiedPrivacyPolicy = spy(privacyPolicy); + + const existingEntity = await enforceAsyncResult( entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') - ) - ).rejects.toBeInstanceOf(Error); + ); - verify( - spiedPrivacyPolicy.authorizeDeleteAsync( - viewerContext, - anyOfClass(EntityTransactionalQueryContext), - anyOfClass(TestEntity) - ) - ).once(); + await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); + + verify( + spiedPrivacyPolicy.authorizeDeleteAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).once(); + }); + + it('executes triggers', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { + mutationTriggers, + entityMutatorFactory, + entityLoaderFactory, + } = createEntityMutatorFactory([ + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '3', + numberField: 3, + dateField: new Date(), + }, + ]); + + const triggerSpies = setUpMutationTriggerSpies(mutationTriggers); + + const existingEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); + + await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); + + verifyTriggerCounts(viewerContext, triggerSpies, { + beforeCreate: false, + afterCreate: false, + beforeUpdate: false, + afterUpdate: false, + beforeDelete: true, + afterDelete: true, + }); + }); }); it('invalidates cache for fields upon create', async () => { @@ -306,6 +656,7 @@ describe(EntityMutatorFactory, () => { simpleTestEntityConfiguration, SimpleTestEntity, instance(privacyPolicyMock), + {}, entityLoaderFactory, databaseAdapter, metricsAdapter @@ -381,6 +732,7 @@ describe(EntityMutatorFactory, () => { simpleTestEntityConfiguration, SimpleTestEntity, privacyPolicy, + {}, entityLoaderFactory, instance(databaseAdapterMock), metricsAdapter diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index 77ac780a..5eaa716a 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -16,6 +16,7 @@ export * from './EntityFields'; export { default as EntityLoader } from './EntityLoader'; export { default as EntityLoaderFactory } from './EntityLoaderFactory'; export * from './EntityMutator'; +export * from './EntityMutationTrigger'; export { default as EntityMutatorFactory } from './EntityMutatorFactory'; export { default as EntityPrivacyPolicy } from './EntityPrivacyPolicy'; export * from './EntityPrivacyPolicy';