diff --git a/.gitignore b/.gitignore index 50d60ad9..a9cc92e5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,12 +23,19 @@ node_modules/ .yarn-integrity # yarn v2 - .yarn/cache .yarn/unplugged .yarn/build-state.yml .pnp.* +# [yarn](https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored) +**/.yarn/* +!**/.yarn/plugins +!**/.yarn/releases +!**/.yarn/sdks +!**/.yarn/versions +**/.pnp.* + # Entity-specific ignores build/ diff --git a/packages/entity/src/EntityCompanion.ts b/packages/entity/src/EntityCompanion.ts index efcae2a6..9553e7cc 100644 --- a/packages/entity/src/EntityCompanion.ts +++ b/packages/entity/src/EntityCompanion.ts @@ -7,6 +7,7 @@ import ReadonlyEntity from './ReadonlyEntity'; import ViewerContext from './ViewerContext'; import EntityTableDataCoordinator from './internal/EntityTableDataCoordinator'; import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter'; +import { mergeEntityMutationTriggerConfigurations } from './utils/mergeEntityMutationTriggerConfigurations'; export interface IPrivacyPolicyClass { new (): TPrivacyPolicy; @@ -76,7 +77,10 @@ export default class EntityCompanion< entityCompanionDefinition.entityClass, this.privacyPolicy, entityCompanionDefinition.mutationValidators ?? [], - entityCompanionDefinition.mutationTriggers ?? {}, + mergeEntityMutationTriggerConfigurations( + entityCompanionDefinition.mutationTriggers ?? {}, + entityCompanionProvider.globalMutationTriggers ?? {}, + ), this.entityLoaderFactory, tableDataCoordinator.databaseAdapter, metricsAdapter, diff --git a/packages/entity/src/EntityCompanionProvider.ts b/packages/entity/src/EntityCompanionProvider.ts index f0eee03d..c29588f5 100644 --- a/packages/entity/src/EntityCompanionProvider.ts +++ b/packages/entity/src/EntityCompanionProvider.ts @@ -139,6 +139,7 @@ export default class EntityCompanionProvider { * @param metricsAdapter - An IEntityMetricsAdapter for collecting metrics on this instance * @param databaseAdapterFlavors - Database adapter configurations for this instance * @param cacheAdapterFlavors - Cache adapter configurations for this instance + * @param globalMutationTriggers - Optional set of EntityMutationTrigger to run for all entity mutations systemwide. */ constructor( public readonly metricsAdapter: IEntityMetricsAdapter, @@ -147,6 +148,13 @@ export default class EntityCompanionProvider { DatabaseAdapterFlavorDefinition >, private cacheAdapterFlavors: ReadonlyMap, + readonly globalMutationTriggers: EntityMutationTriggerConfiguration< + any, + any, + any, + any, + any + > = {}, ) {} /** diff --git a/packages/entity/src/__tests__/EntityCompanion-test.ts b/packages/entity/src/__tests__/EntityCompanion-test.ts index 70197e3d..3d609c13 100644 --- a/packages/entity/src/__tests__/EntityCompanion-test.ts +++ b/packages/entity/src/__tests__/EntityCompanion-test.ts @@ -3,25 +3,77 @@ import { instance, mock, when } from 'ts-mockito'; import EntityCompanion from '../EntityCompanion'; import EntityCompanionProvider from '../EntityCompanionProvider'; import EntityLoaderFactory from '../EntityLoaderFactory'; +import EntityMutationTriggerConfiguration from '../EntityMutationTriggerConfiguration'; import EntityMutatorFactory from '../EntityMutatorFactory'; +import ViewerContext from '../ViewerContext'; import EntityTableDataCoordinator from '../internal/EntityTableDataCoordinator'; import IEntityMetricsAdapter from '../metrics/IEntityMetricsAdapter'; -import TestEntity, { testEntityConfiguration, TestFields } from '../testfixtures/TestEntity'; +import NoOpEntityMetricsAdapter from '../metrics/NoOpEntityMetricsAdapter'; +import TestEntityWithMutationTriggers, { + TestMTFields, + testEntityMTConfiguration, + TestMutationTrigger, +} from '../testfixtures/TestEntityWithMutationTriggers'; describe(EntityCompanion, () => { it('correctly instantiates mutator and loader factories', () => { const entityCompanionProvider = instance(mock()); - const tableDataCoordinatorMock = mock>(); - when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityConfiguration); + const tableDataCoordinatorMock = mock>(); + when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityMTConfiguration); const companion = new EntityCompanion( entityCompanionProvider, - TestEntity.defineCompanionDefinition(), + TestEntityWithMutationTriggers.defineCompanionDefinition(), instance(tableDataCoordinatorMock), instance(mock()), ); expect(companion.getLoaderFactory()).toBeInstanceOf(EntityLoaderFactory); expect(companion.getMutatorFactory()).toBeInstanceOf(EntityMutatorFactory); }); + + it('correctly merges local and global mutation triggers', () => { + const globalMutationTriggers: EntityMutationTriggerConfiguration< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers, + keyof TestMTFields + > = { + afterCreate: [new TestMutationTrigger('globalAfterCreate')], + afterAll: [new TestMutationTrigger('globalAfterAll')], + }; + + const metricsAdapter = new NoOpEntityMetricsAdapter(); + + const entityCompanionProvider = new EntityCompanionProvider( + metricsAdapter, + new Map(), + new Map(), + globalMutationTriggers, + ); + + const tableDataCoordinatorMock = mock>(); + when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityMTConfiguration); + + const companion = new EntityCompanion( + entityCompanionProvider, + TestEntityWithMutationTriggers.defineCompanionDefinition(), + instance(tableDataCoordinatorMock), + instance(mock()), + ); + expect(companion.getLoaderFactory()).toBeInstanceOf(EntityLoaderFactory); + expect(companion.getMutatorFactory()).toBeInstanceOf(EntityMutatorFactory); + + const mergedTriggers = companion.getMutatorFactory()['mutationTriggers']; + + const localTriggers = companion.entityCompanionDefinition.mutationTriggers; + expect(localTriggers).toBeTruthy(); + + expect(mergedTriggers).toStrictEqual({ + afterCreate: [localTriggers!.afterCreate![0], globalMutationTriggers.afterCreate![0]], + afterAll: [localTriggers!.afterAll![0], globalMutationTriggers!.afterAll![0]], + afterCommit: [localTriggers!.afterCommit![0]], + }); + }); }); diff --git a/packages/entity/src/testfixtures/TestEntityWithMutationTriggers.ts b/packages/entity/src/testfixtures/TestEntityWithMutationTriggers.ts new file mode 100644 index 00000000..48f23daa --- /dev/null +++ b/packages/entity/src/testfixtures/TestEntityWithMutationTriggers.ts @@ -0,0 +1,156 @@ +import Entity from '../Entity'; +import { EntityCompanionDefinition } from '../EntityCompanionProvider'; +import EntityConfiguration from '../EntityConfiguration'; +import { StringField, UUIDField } from '../EntityFields'; +import { EntityTriggerMutationInfo } from '../EntityMutationInfo'; +import { + EntityMutationTrigger, + EntityNonTransactionalMutationTrigger, +} from '../EntityMutationTriggerConfiguration'; +import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; +import { EntityQueryContext } from '../EntityQueryContext'; +import ViewerContext from '../ViewerContext'; +import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule'; + +export type TestMTFields = { + id: string; + stringField: string; +}; + +export const testEntityMTConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'test_entity_should_not_write_to_db_3', + schema: { + id: new UUIDField({ + columnName: 'id', + }), + stringField: new StringField({ + columnName: 'string_field', + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +export class TestEntityMTPrivacyPolicy extends EntityPrivacyPolicy< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers +> { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers + >(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers + >(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers + >(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers + >(), + ]; +} + +export class TestMutationTrigger extends EntityMutationTrigger< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers, + keyof TestMTFields +> { + constructor( + // @ts-expect-error key is never used but is helpful for debugging + private readonly key: string, + ) { + super(); + } + + async executeAsync( + _viewerContext: ViewerContext, + _queryContext: EntityQueryContext, + _entity: TestEntityWithMutationTriggers, + _mutationInfo: EntityTriggerMutationInfo< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers, + keyof TestMTFields + >, + ): Promise {} +} + +export class NonTransactionalTestMutationTrigger extends EntityNonTransactionalMutationTrigger< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers, + keyof TestMTFields +> { + constructor( + // @ts-expect-error key is never used but is helpful for debugging + private readonly key: string, + ) { + super(); + } + + async executeAsync( + _viewerContext: ViewerContext, + _entity: TestEntityWithMutationTriggers, + _mutationInfo: EntityTriggerMutationInfo< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers, + keyof TestMTFields + >, + ): Promise {} +} + +/** + * A test Entity that has one afterCreate and one afterAll trigger + */ +export default class TestEntityWithMutationTriggers extends Entity< + TestMTFields, + string, + ViewerContext +> { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestMTFields, + string, + ViewerContext, + TestEntityWithMutationTriggers, + TestEntityMTPrivacyPolicy + > { + return { + entityClass: TestEntityWithMutationTriggers, + entityConfiguration: testEntityMTConfiguration, + privacyPolicyClass: TestEntityMTPrivacyPolicy, + mutationTriggers: { + afterCreate: [new TestMutationTrigger('localAfterCreate')], + afterAll: [new TestMutationTrigger('localAfterAll')], + afterCommit: [new NonTransactionalTestMutationTrigger('localAfterCommit')], + }, + }; + } +} diff --git a/packages/entity/src/utils/__tests__/mergeEntityMutationTriggerConfigurations-test.ts b/packages/entity/src/utils/__tests__/mergeEntityMutationTriggerConfigurations-test.ts new file mode 100644 index 00000000..291143e2 --- /dev/null +++ b/packages/entity/src/utils/__tests__/mergeEntityMutationTriggerConfigurations-test.ts @@ -0,0 +1,29 @@ +import { TestMutationTrigger } from '../../testfixtures/TestEntityWithMutationTriggers'; +import { mergeEntityMutationTriggerConfigurations } from '../mergeEntityMutationTriggerConfigurations'; + +describe(mergeEntityMutationTriggerConfigurations, () => { + it('successfully merges triggers', async () => { + const firstAfter = new TestMutationTrigger('2'); + const secondAfter = new TestMutationTrigger('3'); + + const merged = mergeEntityMutationTriggerConfigurations( + { + beforeAll: [new TestMutationTrigger('1')], + afterAll: [firstAfter], + }, + { + afterAll: [secondAfter], + }, + ); + + expect(merged.beforeAll?.length).toBe(1); + expect(merged.afterAll).toEqual([firstAfter, secondAfter]); + expect(merged.beforeCreate?.length).toBeFalsy(); + expect(merged.afterCreate?.length).toBeFalsy(); + expect(merged.beforeUpdate?.length).toBeFalsy(); + expect(merged.afterUpdate?.length).toBeFalsy(); + expect(merged.beforeDelete?.length).toBeFalsy(); + expect(merged.afterDelete?.length).toBeFalsy(); + expect(merged.afterCommit?.length).toBeFalsy(); + }); +}); diff --git a/packages/entity/src/utils/mergeEntityMutationTriggerConfigurations.ts b/packages/entity/src/utils/mergeEntityMutationTriggerConfigurations.ts new file mode 100644 index 00000000..a3a64130 --- /dev/null +++ b/packages/entity/src/utils/mergeEntityMutationTriggerConfigurations.ts @@ -0,0 +1,44 @@ +import EntityMutationTriggerConfiguration from '../EntityMutationTriggerConfiguration'; +import ReadonlyEntity from '../ReadonlyEntity'; +import ViewerContext from '../ViewerContext'; + +function nonNullish(value: TValue | null | undefined): value is NonNullable { + return value !== null && value !== undefined; +} + +export function mergeEntityMutationTriggerConfigurations< + TFields extends object, + TID extends NonNullable, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TSelectedFields extends keyof TFields, +>( + ...mutationTriggerConfigurations: EntityMutationTriggerConfiguration< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >[] +): EntityMutationTriggerConfiguration { + const merged = { + beforeCreate: mutationTriggerConfigurations.flatMap((c) => c.beforeCreate).filter(nonNullish), + afterCreate: mutationTriggerConfigurations.flatMap((c) => c.afterCreate).filter(nonNullish), + beforeUpdate: mutationTriggerConfigurations.flatMap((c) => c.beforeUpdate).filter(nonNullish), + afterUpdate: mutationTriggerConfigurations.flatMap((c) => c.afterUpdate).filter(nonNullish), + beforeDelete: mutationTriggerConfigurations.flatMap((c) => c.beforeDelete).filter(nonNullish), + afterDelete: mutationTriggerConfigurations.flatMap((c) => c.afterDelete).filter(nonNullish), + beforeAll: mutationTriggerConfigurations.flatMap((c) => c.beforeAll).filter(nonNullish), + afterAll: mutationTriggerConfigurations.flatMap((c) => c.afterAll).filter(nonNullish), + afterCommit: mutationTriggerConfigurations.flatMap((c) => c.afterCommit).filter(nonNullish), + }; + + /** Remove any trigger that is an empty array */ + for (const key of Object.keys(merged) as (keyof typeof merged)[]) { + if (merged[key].length === 0) { + delete merged[key]; + } + } + + return merged; +}