Skip to content

Commit

Permalink
feat(core): execute hooks via EventManager (#623)
Browse files Browse the repository at this point in the history
Lifecycle hooks are now executed the same way as `EntitySubscriber`s, this means that they
will also get the `EventArgs` in the parameters.

`EventArgs` now also optionally contain the `ChangeSet` object, so it is possible to get
the information about what fields actually changed or access the original entity data.

Closes #622
  • Loading branch information
B4nan committed Aug 9, 2020
1 parent 6435922 commit 6a7f627
Show file tree
Hide file tree
Showing 9 changed files with 75 additions and 44 deletions.
18 changes: 18 additions & 0 deletions docs/docs/lifecycle-hooks.md
Expand Up @@ -9,6 +9,10 @@ There are two ways to hook to the lifecycle of an entity:
- **EventSubscriber**s are classes that can be used to hook to multiple entities
or when you do not want to have the method present on the entity prototype.

> Hooks are internally executed the same way as subscribers.
> Hooks are executed before subscribers.
## Hooks

You can use lifecycle hooks to run some code when entity gets persisted. You can mark any of
Expand Down Expand Up @@ -102,3 +106,17 @@ export class EverythingSubscriber implements EventSubscriber {

}
```

## EventArgs

As a parameter to the hook method we get `EventArgs` instance. It will always contain
reference to the current `EntityManager` and the particular entity. Events fired
from `UnitOfWork` during flush operation also contain the `ChangeSet` object.

```typescript
interface EventArgs<T> {
entity: T;
em: EntityManager;
changeSet?: ChangeSet<T>;
}
```
2 changes: 1 addition & 1 deletion packages/core/src/entity/EntityFactory.ts
Expand Up @@ -150,7 +150,7 @@ export class EntityFactory {
hooks.forEach(hook => (entity[hook] as unknown as () => void)());
}

this.em.getEventManager().dispatchEvent(EventType.onInit, entity, this.em);
this.em.getEventManager().dispatchEvent(EventType.onInit, entity, { em: this.em });
}

}
22 changes: 13 additions & 9 deletions packages/core/src/events/EventManager.ts
@@ -1,8 +1,8 @@
import { AnyEntity } from '../typings';
import { EntityManager } from '../EntityManager';
import { EventSubscriber } from './EventSubscriber';
import { EventArgs, EventSubscriber } from './EventSubscriber';
import { Utils } from '../utils';
import { EventType } from './EventType';
import { wrap } from '../entity/wrap';

export class EventManager {

Expand All @@ -23,24 +23,28 @@ export class EventManager {
});
}

dispatchEvent(event: EventType.onInit, entity: AnyEntity, em: EntityManager): unknown;
dispatchEvent(event: EventType, entity: AnyEntity, em: EntityManager): Promise<unknown>;
dispatchEvent(event: EventType, entity: AnyEntity, em: EntityManager): Promise<unknown> | unknown {
const listeners: EventSubscriber[] = [];
dispatchEvent<T extends AnyEntity<T>>(event: EventType.onInit, entity: T, args: Partial<EventArgs<T>>): unknown;
dispatchEvent<T extends AnyEntity<T>>(event: EventType, entity: T, args: Partial<EventArgs<T>>): Promise<unknown>;
dispatchEvent<T extends AnyEntity<T>>(event: EventType, entity: T, args: Partial<EventArgs<T>>): Promise<unknown> | unknown {
const listeners: [EventType, EventSubscriber<T>][] = [];

// execute lifecycle hooks first
const hooks = wrap(entity, true).__meta.hooks[event] || [];
listeners.push(...hooks.map(hook => [hook, entity] as [EventType, EventSubscriber<T>]));

for (const listener of this.listeners[event] || []) {
const entities = this.entities.get(listener)!;

if (entities.length === 0 || entities.includes(entity.constructor.name)) {
listeners.push(listener);
listeners.push([event, listener]);
}
}

if (event === EventType.onInit) {
return listeners.forEach(listener => listener[event]!({ em, entity }));
return listeners.forEach(listener => listener[1][listener[0]]!({ ...args, entity } as EventArgs<T>));
}

return Utils.runSerial(listeners, listener => listener[event]!({ em, entity }));
return Utils.runSerial(listeners, listener => listener[1][listener[0]]!({ ...args, entity } as EventArgs<T>) as Promise<void>);
}

private getSubscribedEntities(listener: EventSubscriber): string[] {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/events/EventSubscriber.ts
@@ -1,9 +1,11 @@
import { AnyEntity, EntityName } from '../typings';
import { EntityManager } from '../EntityManager';
import { ChangeSet } from '../unit-of-work';

export interface EventArgs<T> {
entity: T;
em: EntityManager;
changeSet?: ChangeSet<T>;
}

export interface EventSubscriber<T = AnyEntity> {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/unit-of-work/ChangeSet.ts
Expand Up @@ -7,6 +7,7 @@ export interface ChangeSet<T extends AnyEntity<T>> {
entity: T;
payload: EntityData<T>;
persisted: boolean;
originalEntity?: EntityData<T>;
}

export enum ChangeSetType {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/unit-of-work/ChangeSetComputer.ts
Expand Up @@ -24,6 +24,10 @@ export class ChangeSetComputer {
changeSet.collection = meta.collection;
changeSet.payload = this.computePayload(entity);

if (changeSet.type === ChangeSetType.UPDATE) {
changeSet.originalEntity = this.originalEntityData[wrap(entity, true).__uuid];
}

this.validator.validate<T>(changeSet.entity, changeSet.payload, meta);

for (const prop of Object.values(meta.properties)) {
Expand Down
24 changes: 6 additions & 18 deletions packages/core/src/unit-of-work/UnitOfWork.ts
Expand Up @@ -268,7 +268,9 @@ export class UnitOfWork {
}

const type = changeSet.type.charAt(0).toUpperCase() + changeSet.type.slice(1);
await this.runHooks(`before${type}` as EventType, changeSet.entity, changeSet.payload);
const copy = Utils.prepareEntity(changeSet.entity, this.metadata, this.platform) as T;
await this.runHooks(`before${type}` as EventType, changeSet);
Object.assign(changeSet.payload, Utils.diffEntities<T>(copy, changeSet.entity, this.metadata, this.platform));
await this.changeSetPersister.persistToDatabase(changeSet, ctx);

switch (changeSet.type) {
Expand All @@ -277,25 +279,11 @@ export class UnitOfWork {
case ChangeSetType.DELETE: this.unsetIdentity(changeSet.entity as T); break;
}

await this.runHooks(`after${type}` as EventType, changeSet.entity as T);
await this.runHooks(`after${type}` as EventType, changeSet);
}

private async runHooks<T extends AnyEntity<T>>(type: EventType, entity: T, payload?: EntityData<T>) {
const hooks = this.metadata.get<T>(entity.constructor.name).hooks;

/* istanbul ignore next */
if ((hooks?.[type]?.length || 0) === 0) {
return await this.em.getEventManager().dispatchEvent(type, entity, this.em);
}

const copy = Utils.prepareEntity(entity, this.metadata, this.platform);
await Utils.runSerial(hooks[type]!, hook => (entity[hook] as unknown as () => Promise<any>)());

if (payload) {
Object.assign(payload, Utils.diffEntities<T>(copy as T, entity, this.metadata, this.platform));
}

await this.em.getEventManager().dispatchEvent(type, entity, this.em);
private async runHooks<T extends AnyEntity<T>>(type: EventType, changeSet: ChangeSet<T>) {
await this.em.getEventManager().dispatchEvent(type, changeSet.entity, { em: this.em, changeSet });
}

/**
Expand Down
28 changes: 17 additions & 11 deletions tests/EntityManager.mysql.test.ts
Expand Up @@ -1157,16 +1157,21 @@ describe('EntityManagerMySql', () => {
expect(author.id).toBeUndefined();
expect(author.version).toBeUndefined();
expect(author.versionAsString).toBeUndefined();
expect(author.hookParams).toHaveLength(0);

await repo.persistAndFlush(author);
expect(author.id).toBeDefined();
expect(author.version).toBe(1);
expect(author.versionAsString).toBe('v1');
expect(author.hookParams[0].em).toBe(orm.em);
expect(author.hookParams[0].changeSet).toMatchObject({ entity: author, type: 'create', payload: { name: 'Jon Snow' } });

author.name = 'John Snow';
await repo.persistAndFlush(author);
expect(author.version).toBe(2);
expect(author.versionAsString).toBe('v2');
expect(author.hookParams[2].em).toBe(orm.em);
expect(author.hookParams[2].changeSet).toMatchObject({ entity: author, type: 'update', payload: { name: 'John Snow' }, originalEntity: { name: 'Jon Snow' } });

expect(Author2.beforeDestroyCalled).toBe(0);
expect(Author2.afterDestroyCalled).toBe(0);
Expand All @@ -1179,17 +1184,18 @@ describe('EntityManagerMySql', () => {
await repo.removeAndFlush(author2);
expect(Author2.beforeDestroyCalled).toBe(2);
expect(Author2.afterDestroyCalled).toBe(2);
expect(Author2Subscriber.log).toEqual([
['beforeCreate', { em: orm.em, entity: author }],
['afterCreate', { em: orm.em, entity: author }],
['beforeUpdate', { em: orm.em, entity: author }],
['afterUpdate', { em: orm.em, entity: author }],
['beforeDelete', { em: orm.em, entity: author }],
['afterDelete', { em: orm.em, entity: author }],
['beforeCreate', { em: orm.em, entity: author2 }],
['afterCreate', { em: orm.em, entity: author2 }],
['beforeDelete', { em: orm.em, entity: author2 }],
['afterDelete', { em: orm.em, entity: author2 }],

expect(Author2Subscriber.log.map(l => [l[0], l[1].entity.constructor.name])).toEqual([
['beforeCreate', 'Author2'],
['afterCreate', 'Author2'],
['beforeUpdate', 'Author2'],
['afterUpdate', 'Author2'],
['beforeDelete', 'Author2'],
['afterDelete', 'Author2'],
['beforeCreate', 'Author2'],
['afterCreate', 'Author2'],
['beforeDelete', 'Author2'],
['afterDelete', 'Author2'],
]);
});

Expand Down
18 changes: 13 additions & 5 deletions tests/entities-sql/Author2.ts
@@ -1,6 +1,6 @@
import {
AfterCreate, AfterDelete, AfterUpdate, BeforeCreate, BeforeDelete, BeforeUpdate, Collection, Entity, OneToMany, Property, ManyToOne,
QueryOrder, OnInit, ManyToMany, DateType, TimeType, Index, Unique, OneToOne, Cascade, LoadStrategy,
QueryOrder, OnInit, ManyToMany, DateType, TimeType, Index, Unique, OneToOne, Cascade, LoadStrategy, EventArgs,
} from '@mikro-orm/core';

import { Book2 } from './Book2';
Expand Down Expand Up @@ -84,6 +84,8 @@ export class Author2 extends BaseEntity2 {
@Property({ persist: false })
booksTotal!: number;

hookParams: any[] = [];

constructor(name: string, email: string) {
super();
this.name = name;
Expand All @@ -93,26 +95,32 @@ export class Author2 extends BaseEntity2 {
@OnInit()
onInit() {
this.code = `${this.email} - ${this.name}`;
this.hookParams = [];
}

@BeforeCreate()
beforeCreate() {
beforeCreate(args: EventArgs<this>) {
this.version = 1;
this.hookParams.push(args);
}

@AfterCreate()
afterCreate() {
afterCreate(args: EventArgs<this>) {
this.versionAsString = 'v' + this.version;
this.hookParams.push(args);
}

@BeforeUpdate()
beforeUpdate() {
beforeUpdate(args: EventArgs<this>) {
this.version += 1;
console.log(this);
this.hookParams.push(args);
}

@AfterUpdate()
afterUpdate() {
afterUpdate(args: EventArgs<this>) {
this.versionAsString = 'v' + this.version;
this.hookParams.push(args);
}

@BeforeDelete()
Expand Down

0 comments on commit 6a7f627

Please sign in to comment.