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: mutation triggers #65

Merged
merged 1 commit into from
Jul 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { enforceAsyncResult } from '@expo/results';
import Knex from 'knex';

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

describe('postgres entity integration', () => {
Expand Down Expand Up @@ -325,4 +326,164 @@ describe('postgres entity integration', () => {
expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']);
});
});

describe('trigger transaction behavior', () => {
describe('create', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);

await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'beforeCreate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeCreate')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterCreate')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterCreate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCreate')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'beforeAll').enforceCreateAsync()
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.creator(vc1).setField('name', 'afterAll').enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterCommit')
.enforceCreateAsync()
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
).resolves.not.toBeNull();
});
});

describe('update', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);

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

await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'beforeUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeUpdate')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterUpdate')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterUpdate');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterUpdate')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'beforeAll')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value beforeAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeAll')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterAll')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterAll');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterAll')
).resolves.toBeNull();

await expect(
PostgresTriggerTestEntity.updater(entity)
.setField('name', 'afterCommit')
.enforceUpdateAsync()
).rejects.toThrowError('name cannot have value afterCommit');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterCommit')
).resolves.not.toBeNull();
});
});

describe('delete', () => {
it('rolls back transaction when trigger throws except afterCommit', async () => {
const vc1 = new ViewerContext(
createKnexIntegrationTestEntityCompanionProvider(knexInstance)
);

const entityBeforeDelete = await PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'beforeDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityBeforeDelete)
).rejects.toThrowError('name cannot have value beforeDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'beforeDelete')
).resolves.not.toBeNull();

const entityAfterDelete = await PostgresTriggerTestEntity.creator(vc1)
.setField('name', 'afterDelete')
.enforceCreateAsync();
await expect(
PostgresTriggerTestEntity.enforceDeleteAsync(entityAfterDelete)
).rejects.toThrowError('name cannot have value afterDelete');
await expect(
PostgresTriggerTestEntity.loader(vc1)
.enforcing()
.loadByFieldEqualingAsync('name', 'afterDelete')
).resolves.not.toBeNull();
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityPrivacyPolicy,
ViewerContext,
UUIDField,
StringField,
EntityConfiguration,
DatabaseAdapterFlavor,
CacheAdapterFlavor,
EntityCompanionDefinition,
Entity,
EntityMutationTrigger,
EntityQueryContext,
} from '@expo/entity';
import Knex from 'knex';

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

export default class PostgresTriggerTestEntity extends Entity<
PostgresTriggerTestEntityFields,
string,
ViewerContext
> {
static getCompanionDefinition(): EntityCompanionDefinition<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity,
PostgresTriggerTestEntityPrivacyPolicy
> {
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 PostgresTriggerTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
> {
protected readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
protected readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
protected readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
protected readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
PostgresTriggerTestEntityFields,
string,
ViewerContext,
PostgresTriggerTestEntity
>(),
];
}

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

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

export const postgresTestEntityConfiguration = new EntityConfiguration<
PostgresTriggerTestEntityFields
>({
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: PostgresTriggerTestEntity,
entityConfiguration: postgresTestEntityConfiguration,
privacyPolicyClass: PostgresTriggerTestEntityPrivacyPolicy,
mutationTriggers: {
beforeCreate: [new ThrowConditionallyTrigger('name', 'beforeCreate')],
afterCreate: [new ThrowConditionallyTrigger('name', 'afterCreate')],
beforeUpdate: [new ThrowConditionallyTrigger('name', 'beforeUpdate')],
afterUpdate: [new ThrowConditionallyTrigger('name', 'afterUpdate')],
beforeDelete: [new ThrowConditionallyTrigger('name', 'beforeDelete')],
afterDelete: [new ThrowConditionallyTrigger('name', 'afterDelete')],
beforeAll: [new ThrowConditionallyTrigger('name', 'beforeAll')],
afterAll: [new ThrowConditionallyTrigger('name', 'afterAll')],
afterCommit: [new ThrowConditionallyTrigger('name', 'afterCommit')],
},
});
9 changes: 9 additions & 0 deletions packages/entity/src/EntityCompanion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IEntityClass } from './Entity';
import EntityLoaderFactory from './EntityLoaderFactory';
import { EntityMutationTriggerConfiguration } from './EntityMutationTrigger';
import EntityMutatorFactory from './EntityMutatorFactory';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import IEntityQueryContextProvider from './IEntityQueryContextProvider';
Expand Down Expand Up @@ -57,6 +58,13 @@ export default class EntityCompanion<
>,
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields>,
PrivacyPolicyClass: IPrivacyPolicyClass<TPrivacyPolicy>,
mutationTriggers: EntityMutationTriggerConfiguration<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>,
metricsAdapter: IEntityMetricsAdapter
) {
const privacyPolicy = new PrivacyPolicyClass();
Expand All @@ -70,6 +78,7 @@ export default class EntityCompanion<
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
mutationTriggers,
this.entityLoaderFactory,
tableDataCoordinator.databaseAdapter,
metricsAdapter
Expand Down
Loading