Skip to content

Commit

Permalink
feat: Add global mutation trigger field to EntityCompanionProvider (#215
Browse files Browse the repository at this point in the history
)

* Add global mutation trigger field to EntityCompanionProvider

* Add globalMutationTrigger to additional test cases

* merge mutation triggers outside of EntityMutator

* Exclude triggers with empty arrays
  • Loading branch information
Josh-McFarlin authored Jun 11, 2024
1 parent 778d9e3 commit 6569486
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 6 deletions.
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
6 changes: 5 additions & 1 deletion packages/entity/src/EntityCompanion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TPrivacyPolicy> {
new (): TPrivacyPolicy;
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/entity/src/EntityCompanionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -147,6 +148,13 @@ export default class EntityCompanionProvider {
DatabaseAdapterFlavorDefinition
>,
private cacheAdapterFlavors: ReadonlyMap<CacheAdapterFlavor, CacheAdapterFlavorDefinition>,
readonly globalMutationTriggers: EntityMutationTriggerConfiguration<
any,
any,
any,
any,
any
> = {},
) {}

/**
Expand Down
60 changes: 56 additions & 4 deletions packages/entity/src/__tests__/EntityCompanion-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EntityCompanionProvider>());

const tableDataCoordinatorMock = mock<EntityTableDataCoordinator<TestFields>>();
when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityConfiguration);
const tableDataCoordinatorMock = mock<EntityTableDataCoordinator<TestMTFields>>();
when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityMTConfiguration);

const companion = new EntityCompanion(
entityCompanionProvider,
TestEntity.defineCompanionDefinition(),
TestEntityWithMutationTriggers.defineCompanionDefinition(),
instance(tableDataCoordinatorMock),
instance(mock<IEntityMetricsAdapter>()),
);
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<EntityTableDataCoordinator<TestMTFields>>();
when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityMTConfiguration);

const companion = new EntityCompanion(
entityCompanionProvider,
TestEntityWithMutationTriggers.defineCompanionDefinition(),
instance(tableDataCoordinatorMock),
instance(mock<IEntityMetricsAdapter>()),
);
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]],
});
});
});
156 changes: 156 additions & 0 deletions packages/entity/src/testfixtures/TestEntityWithMutationTriggers.ts
Original file line number Diff line number Diff line change
@@ -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<TestMTFields>({
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<void> {}
}

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<void> {}
}

/**
* 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')],
},
};
}
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import EntityMutationTriggerConfiguration from '../EntityMutationTriggerConfiguration';
import ReadonlyEntity from '../ReadonlyEntity';
import ViewerContext from '../ViewerContext';

function nonNullish<TValue>(value: TValue | null | undefined): value is NonNullable<TValue> {
return value !== null && value !== undefined;
}

export function mergeEntityMutationTriggerConfigurations<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields,
>(
...mutationTriggerConfigurations: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[]
): EntityMutationTriggerConfiguration<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
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;
}

0 comments on commit 6569486

Please sign in to comment.