Skip to content

Commit

Permalink
feat: entity mutation validators (#67)
Browse files Browse the repository at this point in the history
* entity mutation validators

* pr feedback

* tests
  • Loading branch information
quinlanj authored Jul 23, 2020
1 parent 5744c51 commit fc4377d
Show file tree
Hide file tree
Showing 8 changed files with 424 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Knex from 'knex';

import PostgresTestEntity from '../testfixtures/PostgresTestEntity';
import PostgresTriggerTestEntity from '../testfixtures/PostgresTriggerTestEntity';
import PostgresValidatorTestEntity from '../testfixtures/PostgresValidatorTestEntity';
import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider';

describe('postgres entity integration', () => {
Expand Down Expand Up @@ -485,5 +486,64 @@ describe('postgres entity integration', () => {
).resolves.not.toBeNull();
});
});
describe('validation transaction behavior', () => {
describe('create', () => {
it('rolls back transaction when trigger throws ', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);

await expect(
PostgresValidatorTestEntity.creator(vc1)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
).resolves.toBeNull();
});
});
describe('update', () => {
it('rolls back transaction when trigger throws ', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);

const entity = await PostgresValidatorTestEntity.creator(vc1)
.setField('name', 'blah')
.enforceCreateAsync();

await expect(
PostgresValidatorTestEntity.updater(entity)
.setField('name', 'beforeCreateAndBeforeUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeCreateAndBeforeUpdate');
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreateAndBeforeUpdate')
).resolves.toBeNull();
});
});
describe('delete', () => {
it('validation should not run on a delete mutation', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);

const entityToDelete = await PostgresValidatorTestEntity.creator(vc1)
.setField('name', 'shouldBeDeleted')
.enforceCreateAsync();
await PostgresValidatorTestEntity.enforceDeleteAsync(entityToDelete);
await expect(
PostgresValidatorTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'shouldBeDeleted')
).resolves.toBeNull();
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
StringField,
EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition,
Entity,
EntityMutationTrigger,
EntityQueryContext,
} from '@expo/entity';
import Knex from 'knex';

type PostgresValidatorTestEntityFields = {
id: string;
name: string | null;
};

export default class PostgresValidatorTestEntity extends Entity<
PostgresValidatorTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity,
PostgresValidatorTestEntityPrivacyPolicy
> {
return postgresTestEntityCompanionDefinition;
}

public static async createOrTruncatePostgresTable(knex: Knex): Promise<void> {
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<void> {
const tableName = this.getCompanionDefinition().entityConfiguration.tableName;
const hasTable = await knex.schema.hasTable(tableName);
if (hasTable) {
await knex.schema.dropTable(tableName);
}
}
}

class PostgresValidatorTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
> {
protected readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
protected readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
protected readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
protected readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
>(),
];
}

class ThrowConditionallyTrigger extends EntityMutationTrigger<
PostgresValidatorTestEntityFields,
string,
ViewerContext,
PostgresValidatorTestEntity
> {
constructor(
private fieldName: keyof PostgresValidatorTestEntityFields,
private badValue: string
) {
super();
}

async executeAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
entity: PostgresValidatorTestEntity
): Promise<void> {
if (entity.getField(this.fieldName) === this.badValue) {
throw new Error(`${this.fieldName} cannot have value ${this.badValue}`);
}
}
}

export const postgresTestEntityConfiguration = new EntityConfiguration<
PostgresValidatorTestEntityFields
>({
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: PostgresValidatorTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresValidatorTestEntityPrivacyPolicy,
mutationValidators: [new ThrowConditionallyTrigger('name', 'beforeCreateAndBeforeUpdate')],
});
10 changes: 9 additions & 1 deletion packages/entity/src/EntityCompanion.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IEntityClass } from './Entity';
import EntityLoaderFactory from './EntityLoaderFactory';
import { EntityMutationTriggerConfiguration } from './EntityMutationTrigger';
import { EntityMutationTriggerConfiguration, EntityMutationTrigger } from './EntityMutationTrigger';
import EntityMutatorFactory from './EntityMutatorFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import IEntityQueryContextProvider from './IEntityQueryContextProvider';
Expand Down Expand Up @@ -58,6 +58,13 @@ export default class EntityCompanion<
>,
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>,
PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>,
mutationValidators: EntityMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
Expand All @@ -78,6 +85,7 @@ export default class EntityCompanion<
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
mutationValidators,
mutationTriggers,
this.entityLoaderFactory,
tableDataCoordinator.databaseAdapter,
Expand Down
19 changes: 18 additions & 1 deletion packages/entity/src/EntityCompanionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { IEntityClass } from './Entity';
import EntityCompanion, { IPrivacyPolicyClass } from './EntityCompanion';
import EntityConfiguration from './EntityConfiguration';
import { EntityMutationTriggerConfiguration } from './EntityMutationTrigger';
import { EntityMutationTriggerConfiguration, EntityMutationTrigger } from './EntityMutationTrigger';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import IEntityCacheAdapterProvider from './IEntityCacheAdapterProvider';
import IEntityDatabaseAdapterProvider from './IEntityDatabaseAdapterProvider';
Expand Down Expand Up @@ -81,6 +81,13 @@ export class EntityCompanionDefinition<
>;
readonly entityConfiguration: EntityConfiguration<TFields>;
readonly privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
readonly mutationValidators: EntityMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
readonly mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
Expand All @@ -94,6 +101,7 @@ export class EntityCompanionDefinition<
entityClass,
entityConfiguration,
privacyPolicyClass,
mutationValidators = [],
mutationTriggers = {},
entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[],
}: {
Expand All @@ -107,6 +115,13 @@ export class EntityCompanionDefinition<
>;
entityConfiguration: EntityConfiguration<TFields>;
privacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>;
mutationValidators?: EntityMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[];
mutationTriggers?: EntityMutationTriggerConfiguration<
TFields,
TID,
Expand All @@ -119,6 +134,7 @@ export class EntityCompanionDefinition<
this.entityClass = entityClass;
this.entityConfiguration = entityConfiguration;
this.privacyPolicyClass = privacyPolicyClass;
this.mutationValidators = mutationValidators;
this.mutationTriggers = mutationTriggers;
this.entitySelectedFields = entitySelectedFields;
}
Expand Down Expand Up @@ -201,6 +217,7 @@ export default class EntityCompanionProvider {
entityCompanionDefinition.entityClass,
tableDataCoordinator,
entityCompanionDefinition.privacyPolicyClass,
entityCompanionDefinition.mutationValidators,
entityCompanionDefinition.mutationTriggers,
this.metricsAdapter
);
Expand Down
31 changes: 30 additions & 1 deletion packages/entity/src/EntityMutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import EntityConfiguration from './EntityConfiguration';
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
import { EntityEdgeDeletionBehavior } from './EntityFields';
import EntityLoaderFactory from './EntityLoaderFactory';
import { EntityMutationTriggerConfiguration, EntityMutationTrigger } from './EntityMutationTrigger';
import { EntityMutationTrigger, EntityMutationTriggerConfiguration } from './EntityMutationTrigger';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
Expand Down Expand Up @@ -42,6 +42,13 @@ abstract class BaseMutator<
TSelectedFields
>,
protected readonly privacyPolicy: TPrivacyPolicy,
protected readonly mutationValidators: EntityMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
protected readonly mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
Expand Down Expand Up @@ -158,6 +165,11 @@ export class CreateMutator<
return authorizeCreateResult;
}

await this.executeTriggers(
this.mutationValidators,
queryContext,
temporaryEntityForPrivacyCheck
);
await this.executeTriggers(
this.mutationTriggers.beforeAll,
queryContext,
Expand Down Expand Up @@ -220,6 +232,13 @@ export class UpdateMutator<
TSelectedFields
>,
privacyPolicy: TPrivacyPolicy,
mutationValidators: EntityMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
Expand All @@ -245,6 +264,7 @@ export class UpdateMutator<
entityConfiguration,
entityClass,
privacyPolicy,
mutationValidators,
mutationTriggers,
entityLoaderFactory,
databaseAdapter,
Expand Down Expand Up @@ -314,6 +334,7 @@ export class UpdateMutator<
return authorizeUpdateResult;
}

await this.executeTriggers(this.mutationValidators, queryContext, entityAboutToBeUpdated);
await this.executeTriggers(
this.mutationTriggers.beforeAll,
queryContext,
Expand Down Expand Up @@ -379,6 +400,13 @@ export class DeleteMutator<
TSelectedFields
>,
privacyPolicy: TPrivacyPolicy,
mutationValidators: EntityMutationTrigger<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
Expand All @@ -404,6 +432,7 @@ export class DeleteMutator<
entityConfiguration,
entityClass,
privacyPolicy,
mutationValidators,
mutationTriggers,
entityLoaderFactory,
databaseAdapter,
Expand Down
Loading

0 comments on commit fc4377d

Please sign in to comment.