Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add global mutation trigger field to EntityCompanionProvider #215

Merged
merged 10 commits into from
Jun 11, 2024
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<
Josh-McFarlin marked this conversation as resolved.
Show resolved Hide resolved
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;
}
Loading