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 b542f561..b413931e 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 @@ -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', () => { @@ -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(); + }); + }); + }); }); }); diff --git a/packages/entity-database-adapter-knex/src/testfixtures/PostgresValidatorTestEntity.ts b/packages/entity-database-adapter-knex/src/testfixtures/PostgresValidatorTestEntity.ts new file mode 100644 index 00000000..cce9b54e --- /dev/null +++ b/packages/entity-database-adapter-knex/src/testfixtures/PostgresValidatorTestEntity.ts @@ -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 { + 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 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 { + 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')], +}); diff --git a/packages/entity/src/EntityCompanion.ts b/packages/entity/src/EntityCompanion.ts index edf348e7..eeda9f8d 100644 --- a/packages/entity/src/EntityCompanion.ts +++ b/packages/entity/src/EntityCompanion.ts @@ -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'; @@ -58,6 +58,13 @@ export default class EntityCompanion< >, private readonly tableDataCoordinator: EntityTableDataCoordinator, PrivacyPolicyClass: IPrivacyPolicyClass, + mutationValidators: EntityMutationTrigger< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >[], mutationTriggers: EntityMutationTriggerConfiguration< TFields, TID, @@ -78,6 +85,7 @@ export default class EntityCompanion< tableDataCoordinator.entityConfiguration, entityClass, privacyPolicy, + mutationValidators, mutationTriggers, this.entityLoaderFactory, tableDataCoordinator.databaseAdapter, diff --git a/packages/entity/src/EntityCompanionProvider.ts b/packages/entity/src/EntityCompanionProvider.ts index 7325e15a..02aa0797 100644 --- a/packages/entity/src/EntityCompanionProvider.ts +++ b/packages/entity/src/EntityCompanionProvider.ts @@ -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'; @@ -81,6 +81,13 @@ export class EntityCompanionDefinition< >; readonly entityConfiguration: EntityConfiguration; readonly privacyPolicyClass: IPrivacyPolicyClass; + readonly mutationValidators: EntityMutationTrigger< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >[]; readonly mutationTriggers: EntityMutationTriggerConfiguration< TFields, TID, @@ -94,6 +101,7 @@ export class EntityCompanionDefinition< entityClass, entityConfiguration, privacyPolicyClass, + mutationValidators = [], mutationTriggers = {}, entitySelectedFields = Array.from(entityConfiguration.schema.keys()) as TSelectedFields[], }: { @@ -107,6 +115,13 @@ export class EntityCompanionDefinition< >; entityConfiguration: EntityConfiguration; privacyPolicyClass: IPrivacyPolicyClass; + mutationValidators?: EntityMutationTrigger< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >[]; mutationTriggers?: EntityMutationTriggerConfiguration< TFields, TID, @@ -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; } @@ -201,6 +217,7 @@ export default class EntityCompanionProvider { entityCompanionDefinition.entityClass, tableDataCoordinator, entityCompanionDefinition.privacyPolicyClass, + entityCompanionDefinition.mutationValidators, entityCompanionDefinition.mutationTriggers, this.metricsAdapter ); diff --git a/packages/entity/src/EntityMutator.ts b/packages/entity/src/EntityMutator.ts index 763a6c67..68a5f08b 100644 --- a/packages/entity/src/EntityMutator.ts +++ b/packages/entity/src/EntityMutator.ts @@ -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'; @@ -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, @@ -158,6 +165,11 @@ export class CreateMutator< return authorizeCreateResult; } + await this.executeTriggers( + this.mutationValidators, + queryContext, + temporaryEntityForPrivacyCheck + ); await this.executeTriggers( this.mutationTriggers.beforeAll, queryContext, @@ -220,6 +232,13 @@ export class UpdateMutator< TSelectedFields >, privacyPolicy: TPrivacyPolicy, + mutationValidators: EntityMutationTrigger< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >[], mutationTriggers: EntityMutationTriggerConfiguration< TFields, TID, @@ -245,6 +264,7 @@ export class UpdateMutator< entityConfiguration, entityClass, privacyPolicy, + mutationValidators, mutationTriggers, entityLoaderFactory, databaseAdapter, @@ -314,6 +334,7 @@ export class UpdateMutator< return authorizeUpdateResult; } + await this.executeTriggers(this.mutationValidators, queryContext, entityAboutToBeUpdated); await this.executeTriggers( this.mutationTriggers.beforeAll, queryContext, @@ -379,6 +400,13 @@ export class DeleteMutator< TSelectedFields >, privacyPolicy: TPrivacyPolicy, + mutationValidators: EntityMutationTrigger< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >[], mutationTriggers: EntityMutationTriggerConfiguration< TFields, TID, @@ -404,6 +432,7 @@ export class DeleteMutator< entityConfiguration, entityClass, privacyPolicy, + mutationValidators, mutationTriggers, entityLoaderFactory, databaseAdapter, diff --git a/packages/entity/src/EntityMutatorFactory.ts b/packages/entity/src/EntityMutatorFactory.ts index 9694ce0e..0cbb0d49 100644 --- a/packages/entity/src/EntityMutatorFactory.ts +++ b/packages/entity/src/EntityMutatorFactory.ts @@ -2,7 +2,7 @@ import Entity, { IEntityClass } from './Entity'; import EntityConfiguration from './EntityConfiguration'; import EntityDatabaseAdapter from './EntityDatabaseAdapter'; import EntityLoaderFactory from './EntityLoaderFactory'; -import { EntityMutationTriggerConfiguration } from './EntityMutationTrigger'; +import { EntityMutationTriggerConfiguration, EntityMutationTrigger } from './EntityMutationTrigger'; import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; @@ -37,6 +37,13 @@ export default class EntityMutatorFactory< TSelectedFields >, private readonly privacyPolicy: TPrivacyPolicy, + private readonly mutationValidators: EntityMutationTrigger< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >[], private readonly mutationTriggers: EntityMutationTriggerConfiguration< TFields, TID, @@ -72,6 +79,7 @@ export default class EntityMutatorFactory< this.entityConfiguration, this.entityClass, this.privacyPolicy, + this.mutationValidators, this.mutationTriggers, this.entityLoaderFactory, this.databaseAdapter, @@ -95,6 +103,7 @@ export default class EntityMutatorFactory< this.entityConfiguration, this.entityClass, this.privacyPolicy, + this.mutationValidators, this.mutationTriggers, this.entityLoaderFactory, this.databaseAdapter, @@ -118,6 +127,7 @@ export default class EntityMutatorFactory< this.entityConfiguration, this.entityClass, this.privacyPolicy, + this.mutationValidators, this.mutationTriggers, this.entityLoaderFactory, this.databaseAdapter, diff --git a/packages/entity/src/__tests__/EntityCompanion-test.ts b/packages/entity/src/__tests__/EntityCompanion-test.ts index 5aafd716..4d5f9d02 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()) ); diff --git a/packages/entity/src/__tests__/EntityMutator-test.ts b/packages/entity/src/__tests__/EntityMutator-test.ts index 5e4bd199..404a5eec 100644 --- a/packages/entity/src/__tests__/EntityMutator-test.ts +++ b/packages/entity/src/__tests__/EntityMutator-test.ts @@ -56,6 +56,40 @@ class TestMutationTrigger extends EntityMutationTrigger< ): Promise {} } +const setUpMutationValidatorSpies = ( + mutationValidators: EntityMutationTrigger< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + >[] +): EntityMutationTrigger[] => { + return mutationValidators.map((validator) => spy(validator)); +}; + +const verifyValidatorCounts = ( + viewerContext: ViewerContext, + mutationValidatorSpies: EntityMutationTrigger< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + >[], + expectedCalls: number +): void => { + for (const validator of mutationValidatorSpies) { + verify( + validator.executeAsync( + viewerContext, + anyOfClass(EntityTransactionalQueryContext), + anyOfClass(TestEntity) + ) + ).times(expectedCalls); + } +}; + const setUpMutationTriggerSpies = ( mutationTriggers: EntityMutationTriggerConfiguration< TestFields, @@ -177,6 +211,13 @@ const createEntityMutatorFactory = ( TestEntityPrivacyPolicy >; metricsAdapter: IEntityMetricsAdapter; + mutationValidators: EntityMutationTrigger< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + >[]; mutationTriggers: EntityMutationTriggerConfiguration< TestFields, string, @@ -185,6 +226,13 @@ const createEntityMutatorFactory = ( keyof TestFields >; } => { + const mutationValidators: EntityMutationTrigger< + TestFields, + string, + ViewerContext, + TestEntity, + keyof TestFields + >[] = [new TestMutationTrigger()]; const mutationTriggers: EntityMutationTriggerConfiguration< TestFields, string, @@ -230,6 +278,7 @@ const createEntityMutatorFactory = ( testEntityConfiguration, TestEntity, privacyPolicy, + mutationValidators, mutationTriggers, entityLoaderFactory, databaseAdapter, @@ -240,6 +289,7 @@ const createEntityMutatorFactory = ( entityLoaderFactory, entityMutatorFactory, metricsAdapter, + mutationValidators, mutationTriggers, }; }; @@ -344,6 +394,36 @@ describe(EntityMutatorFactory, () => { afterDelete: false, }); }); + + it('executes validators', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { mutationValidators, 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 validatorSpies = setUpMutationValidatorSpies(mutationValidators); + + await entityMutatorFactory + .forCreate(viewerContext, queryContext) + .setField('stringField', 'huh') + .enforceCreateAsync(); + + verifyValidatorCounts(viewerContext, validatorSpies, 1); + }); }); describe('forUpdate', () => { @@ -474,6 +554,44 @@ describe(EntityMutatorFactory, () => { afterDelete: false, }); }); + it('executes validators', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + + const { + mutationValidators, + 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 validatorSpies = setUpMutationValidatorSpies(mutationValidators); + + const existingEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); + + await entityMutatorFactory + .forUpdate(existingEntity, queryContext) + .setField('stringField', 'huh2') + .enforceUpdateAsync(); + + verifyValidatorCounts(viewerContext, validatorSpies, 1); + }); }); describe('forDelete', () => { @@ -572,6 +690,34 @@ describe(EntityMutatorFactory, () => { afterDelete: true, }); }); + + it('does not execute validators', async () => { + const viewerContext = mock(); + const queryContext = StubQueryContextProvider.getQueryContext(); + const { + mutationValidators, + entityMutatorFactory, + entityLoaderFactory, + } = createEntityMutatorFactory([ + { + customIdField: 'world', + stringField: 'huh', + testIndexedField: '3', + numberField: 3, + dateField: new Date(), + }, + ]); + + const validatorSpies = setUpMutationValidatorSpies(mutationValidators); + + const existingEntity = await enforceAsyncResult( + entityLoaderFactory.forLoad(viewerContext, queryContext).loadByIDAsync('world') + ); + + await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync(); + + verifyValidatorCounts(viewerContext, validatorSpies, 0); + }); }); it('invalidates cache for fields upon create', async () => { @@ -656,6 +802,7 @@ describe(EntityMutatorFactory, () => { simpleTestEntityConfiguration, SimpleTestEntity, instance(privacyPolicyMock), + [], {}, entityLoaderFactory, databaseAdapter, @@ -732,6 +879,7 @@ describe(EntityMutatorFactory, () => { simpleTestEntityConfiguration, SimpleTestEntity, privacyPolicy, + [], {}, entityLoaderFactory, instance(databaseAdapterMock),